diff --git a/Cargo.lock b/Cargo.lock index 3313342..1805eda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 4d353bd..f55a585 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/crates/k-launcher-kernel/Cargo.toml b/crates/k-launcher-kernel/Cargo.toml index fb11492..a6fda6d 100644 --- a/crates/k-launcher-kernel/Cargo.toml +++ b/crates/k-launcher-kernel/Cargo.toml @@ -8,3 +8,4 @@ async-trait = { workspace = true } futures = { workspace = true } serde = { workspace = true } tokio = { workspace = true } +tracing = { workspace = true } diff --git a/crates/k-launcher-kernel/src/lib.rs b/crates/k-launcher-kernel/src/lib.rs index 3558460..497a29b 100644 --- a/crates/k-launcher-kernel/src/lib.rs +++ b/crates/k-launcher-kernel/src/lib.rs @@ -97,6 +97,17 @@ pub trait SearchEngine: Send + Sync { async fn search(&self, query: &str) -> Vec; } +// --- NullSearchEngine --- + +pub struct NullSearchEngine; + +#[async_trait] +impl SearchEngine for NullSearchEngine { + async fn search(&self, _query: &str) -> Vec { + vec![] + } +} + // --- Kernel (Application use case) --- pub struct Kernel { @@ -113,9 +124,25 @@ impl Kernel { } pub async fn search(&self, query: &str) -> Vec { - let futures = self.plugins.iter().map(|p| p.search(query)); - let nested: Vec> = join_all(futures).await; - let mut flat: Vec = 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 = 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 { + 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![ diff --git a/crates/k-launcher/Cargo.toml b/crates/k-launcher/Cargo.toml index bdcc2e2..1deff96 100644 --- a/crates/k-launcher/Cargo.toml +++ b/crates/k-launcher/Cargo.toml @@ -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"] } diff --git a/crates/k-launcher/src/main.rs b/crates/k-launcher/src/main.rs index 73ef394..355b36d 100644 --- a/crates/k-launcher/src/main.rs +++ b/crates/k-launcher/src/main.rs @@ -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) -> Arc { let frecency = FrecencyStore::load(); - let mut plugins: Vec> = 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 = - 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 Arc + Send + Sync> = + Arc::new(move || build_engine(factory_cfg.clone())); + k_launcher_ui::run(factory, launcher, &cfg.window, cfg.appearance.clone()) } diff --git a/crates/plugins/plugin-apps/Cargo.toml b/crates/plugins/plugin-apps/Cargo.toml index d7a2531..e1549e8 100644 --- a/crates/plugins/plugin-apps/Cargo.toml +++ b/crates/plugins/plugin-apps/Cargo.toml @@ -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 } diff --git a/crates/plugins/plugin-apps/src/lib.rs b/crates/plugins/plugin-apps/src/lib.rs index e000a57..0208d4e 100644 --- a/crates/plugins/plugin-apps/src/lib.rs +++ b/crates/plugins/plugin-apps/src/lib.rs @@ -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, } +// --- Serializable cache data (no closures) --- + +#[derive(serde::Serialize, serde::Deserialize)] +struct CachedEntryData { + id: String, + name: String, + keywords_lc: Vec, + category: Option, + icon: Option, + exec: String, +} + +fn cache_path() -> Option { + #[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) -> Option> { + let data = std::fs::read(path).ok()?; + let (entries_data, _): (Vec, _) = + 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 = 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) { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).ok(); + } + let data: Vec = 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) -> HashMap { + 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 = None; + let exec = e.exec.as_str().to_string(); + let store = Arc::clone(frecency); + let record_id = id.clone(); + let on_select: Arc = 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, + entries: Arc>>, frecency: Arc, } impl AppsPlugin { - pub fn new(source: impl DesktopEntrySource, frecency: Arc) -> 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 = None; - let exec = e.exec.as_str().to_string(); - let store = Arc::clone(&frecency); - let record_id = id.clone(); - let on_select: Arc = 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) -> 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 { + 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(); + } }