- 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
516 lines
15 KiB
Rust
516 lines
15 KiB
Rust
pub mod frecency;
|
|
#[cfg(target_os = "linux")]
|
|
pub mod linux;
|
|
|
|
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};
|
|
|
|
use crate::frecency::FrecencyStore;
|
|
|
|
// --- Domain newtypes ---
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct AppName(String);
|
|
|
|
impl AppName {
|
|
pub fn new(s: impl Into<String>) -> Self {
|
|
Self(s.into())
|
|
}
|
|
pub fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ExecCommand(String);
|
|
|
|
impl ExecCommand {
|
|
pub fn new(s: impl Into<String>) -> Self {
|
|
Self(s.into())
|
|
}
|
|
pub fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct IconPath(String);
|
|
|
|
impl IconPath {
|
|
pub fn new(s: impl Into<String>) -> Self {
|
|
Self(s.into())
|
|
}
|
|
pub fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
// --- Desktop entry ---
|
|
|
|
pub struct DesktopEntry {
|
|
pub name: AppName,
|
|
pub exec: ExecCommand,
|
|
pub icon: Option<IconPath>,
|
|
pub category: Option<String>,
|
|
pub keywords: Vec<String>,
|
|
}
|
|
|
|
// --- Swappable source trait (Application layer principle) ---
|
|
|
|
pub trait DesktopEntrySource: Send + Sync {
|
|
fn entries(&self) -> Vec<DesktopEntry>;
|
|
}
|
|
|
|
// --- Cached entry (pre-computed at construction) ---
|
|
|
|
struct CachedEntry {
|
|
id: String,
|
|
name: AppName,
|
|
keywords_lc: Vec<String>,
|
|
category: Option<String>,
|
|
icon: Option<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> {
|
|
#[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: Arc<RwLock<HashMap<String, CachedEntry>>>,
|
|
frecency: Arc<FrecencyStore>,
|
|
}
|
|
|
|
impl AppsPlugin {
|
|
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 }
|
|
}
|
|
}
|
|
|
|
fn initials(name_lc: &str) -> String {
|
|
name_lc
|
|
.split_whitespace()
|
|
.filter_map(|w| w.chars().next())
|
|
.collect()
|
|
}
|
|
|
|
fn score_match(name: &str, query: &str) -> Option<u32> {
|
|
use nucleo_matcher::{
|
|
Config, Matcher, Utf32Str,
|
|
pattern::{CaseMatching, Normalization, Pattern},
|
|
};
|
|
|
|
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 {
|
|
let mut result = String::new();
|
|
for ch in s.chars() {
|
|
if ch.is_uppercase() && !result.is_empty() {
|
|
result.push(' ');
|
|
}
|
|
result.push(ch);
|
|
}
|
|
result
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Plugin for AppsPlugin {
|
|
fn name(&self) -> &str {
|
|
"apps"
|
|
}
|
|
|
|
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 = entries.get(id)?;
|
|
let score = self.frecency.frecency_score(id).max(1);
|
|
Some(SearchResult {
|
|
id: ResultId::new(id),
|
|
title: ResultTitle::new(e.name.as_str()),
|
|
description: e.category.clone(),
|
|
icon: e.icon.clone(),
|
|
score: Score::new(score),
|
|
action: LaunchAction::SpawnProcess(e.exec.clone()),
|
|
on_select: Some(Arc::clone(&e.on_select)),
|
|
})
|
|
})
|
|
.collect();
|
|
}
|
|
|
|
let query_lc = query.to_lowercase();
|
|
entries
|
|
.values()
|
|
.filter_map(|e| {
|
|
let score = score_match(e.name.as_str(), query).or_else(|| {
|
|
e.keywords_lc
|
|
.iter()
|
|
.any(|k| k.contains(&query_lc))
|
|
.then_some(50)
|
|
})?;
|
|
Some(SearchResult {
|
|
id: ResultId::new(&e.id),
|
|
title: ResultTitle::new(e.name.as_str()),
|
|
description: e.category.clone(),
|
|
icon: e.icon.clone(),
|
|
score: Score::new(score),
|
|
action: LaunchAction::SpawnProcess(e.exec.clone()),
|
|
on_select: Some(Arc::clone(&e.on_select)),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
// --- Tests ---
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn ephemeral_frecency() -> Arc<FrecencyStore> {
|
|
FrecencyStore::new_for_test()
|
|
}
|
|
|
|
struct MockSource {
|
|
entries: Vec<(String, String, Option<String>, Vec<String>)>, // (name, exec, category, keywords)
|
|
}
|
|
|
|
impl MockSource {
|
|
fn with(entries: Vec<(&str, &str)>) -> Self {
|
|
Self {
|
|
entries: entries
|
|
.into_iter()
|
|
.map(|(n, e)| (n.to_string(), e.to_string(), None, vec![]))
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
fn with_categories(entries: Vec<(&str, &str, &str)>) -> Self {
|
|
Self {
|
|
entries: entries
|
|
.into_iter()
|
|
.map(|(n, e, c)| (n.to_string(), e.to_string(), Some(c.to_string()), vec![]))
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
fn with_keywords(entries: Vec<(&str, &str, Vec<&str>)>) -> Self {
|
|
Self {
|
|
entries: entries
|
|
.into_iter()
|
|
.map(|(n, e, kw)| {
|
|
(
|
|
n.to_string(),
|
|
e.to_string(),
|
|
None,
|
|
kw.into_iter().map(|s| s.to_string()).collect(),
|
|
)
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl DesktopEntrySource for MockSource {
|
|
fn entries(&self) -> Vec<DesktopEntry> {
|
|
self.entries
|
|
.iter()
|
|
.map(|(name, exec, category, keywords)| DesktopEntry {
|
|
name: AppName::new(name.clone()),
|
|
exec: ExecCommand::new(exec.clone()),
|
|
icon: None,
|
|
category: category.clone(),
|
|
keywords: keywords.clone(),
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn apps_prefix_match() {
|
|
let p = AppsPlugin::new(
|
|
MockSource::with(vec![("Firefox", "firefox")]),
|
|
ephemeral_frecency(),
|
|
);
|
|
let results = p.search("fire").await;
|
|
assert_eq!(results[0].title.as_str(), "Firefox");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn apps_no_match_returns_empty() {
|
|
let p = AppsPlugin::new(
|
|
MockSource::with(vec![("Firefox", "firefox")]),
|
|
ephemeral_frecency(),
|
|
);
|
|
assert!(p.search("zz").await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn apps_empty_query_no_frecency_returns_empty() {
|
|
let p = AppsPlugin::new(
|
|
MockSource::with(vec![("Firefox", "firefox")]),
|
|
ephemeral_frecency(),
|
|
);
|
|
assert!(p.search("").await.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn score_match_abbreviation() {
|
|
assert_eq!(initials("visual studio code"), "vsc");
|
|
assert!(score_match("visual studio code", "vsc").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn score_match_exact_beats_prefix_beats_abbrev_beats_substr() {
|
|
let exact = score_match("firefox", "firefox");
|
|
let prefix = score_match("firefox", "fire");
|
|
let abbrev = score_match("gnu firefox", "gf");
|
|
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]
|
|
async fn apps_abbreviation_match() {
|
|
let p = AppsPlugin::new(
|
|
MockSource::with(vec![("Visual Studio Code", "code")]),
|
|
ephemeral_frecency(),
|
|
);
|
|
let results = p.search("vsc").await;
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].title.as_str(), "Visual Studio Code");
|
|
assert!(results[0].score.value() > 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn apps_keyword_match() {
|
|
let p = AppsPlugin::new(
|
|
MockSource::with_keywords(vec![("Code", "code", vec!["editor", "ide"])]),
|
|
ephemeral_frecency(),
|
|
);
|
|
let results = p.search("editor").await;
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].score.value(), 50);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn apps_fuzzy_typo_match() {
|
|
let p = AppsPlugin::new(
|
|
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]
|
|
fn humanize_category_splits_camel_case() {
|
|
assert_eq!(humanize_category("TextEditor"), "Text Editor");
|
|
assert_eq!(humanize_category("WebBrowser"), "Web Browser");
|
|
assert_eq!(humanize_category("Development"), "Development");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn apps_category_appears_in_description() {
|
|
let p = AppsPlugin::new(
|
|
MockSource::with_categories(vec![("Code", "code", "Text Editor")]),
|
|
ephemeral_frecency(),
|
|
);
|
|
let results = p.search("code").await;
|
|
assert_eq!(results[0].description.as_deref(), Some("Text Editor"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn apps_empty_query_returns_top_frecent() {
|
|
let frecency = ephemeral_frecency();
|
|
frecency.record("app-Code");
|
|
frecency.record("app-Code");
|
|
frecency.record("app-Firefox");
|
|
let p = AppsPlugin::new(
|
|
MockSource::with(vec![("Firefox", "firefox"), ("Code", "code")]),
|
|
frecency,
|
|
);
|
|
let results = p.search("").await;
|
|
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();
|
|
}
|
|
}
|