1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use freedesktop_icons::lookup;
5use iced::widget::image::Handle as ImageHandle;
6use iced::widget::svg::Handle as SvgHandle;
7use serde::{Deserialize, Serialize};
8use sled::{Config, Db, IVec};
9
10use crate::{AppDescriptor, IconHandle, DEFAULT_ICON_SIZE, FALLBACK_ICON_HANDLE};
11
12const CACHE_NAMESPACE: &str = "elbey";
13
14static SCAN_KEY: [u8; 4] = 0_i32.to_be_bytes();
15
16#[derive(Debug, Serialize, Deserialize, Clone)]
17enum CachedIcon {
18 Raster(Vec<u8>),
19 Rgba {
20 width: u32,
21 height: u32,
22 pixels: Vec<u8>,
23 },
24 Svg(Vec<u8>),
25}
26
27#[derive(Debug, Serialize, Deserialize, Clone)]
28struct CachedAppDescriptor {
29 pub appid: String,
30 pub title: String,
31 #[serde(default)]
32 pub lower_title: String,
33 pub exec: Option<String>,
34 pub exec_count: usize,
35 pub icon_name: Option<String>,
36 #[serde(default)]
37 pub icon_path: Option<PathBuf>,
38 #[serde(default)]
39 pub icon_data: Option<CachedIcon>,
40}
41
42pub struct Cache {
44 apps_loader: fn() -> Vec<AppDescriptor>,
45 db: Db,
46}
47
48fn is_empty_path(path: &Path) -> bool {
49 path.as_os_str().is_empty()
50}
51
52fn icon_data_from_path(path: &Path) -> Option<CachedIcon> {
53 if is_empty_path(path) {
54 return None;
55 }
56
57 let bytes = std::fs::read(path).ok()?;
58 let is_svg = path
59 .extension()
60 .and_then(|ext| ext.to_str())
61 .map(|ext| ext.eq_ignore_ascii_case("svg") || ext.eq_ignore_ascii_case("svgz"))
62 .unwrap_or(false);
63
64 if is_svg {
65 Some(CachedIcon::Svg(bytes))
66 } else {
67 decode_raster(bytes.as_slice())
68 }
69}
70
71fn icon_handle_from_data(icon_data: &CachedIcon) -> IconHandle {
72 match icon_data {
73 CachedIcon::Raster(bytes) => IconHandle::Raster(ImageHandle::from_bytes(bytes.clone())),
74 CachedIcon::Rgba {
75 width,
76 height,
77 pixels,
78 } => IconHandle::Raster(ImageHandle::from_rgba(*width, *height, pixels.clone())),
79 CachedIcon::Svg(bytes) => IconHandle::Vector(SvgHandle::from_memory(bytes.clone())),
80 }
81}
82
83fn decode_raster(bytes: &[u8]) -> Option<CachedIcon> {
84 let image = image::load_from_memory(bytes).ok()?;
85 let rgba = image.to_rgba8();
86 let (width, height) = rgba.dimensions();
87 Some(CachedIcon::Rgba {
88 width,
89 height,
90 pixels: rgba.into_raw(),
91 })
92}
93
94fn populate_icon_data(entry: &mut CachedAppDescriptor) -> bool {
95 if entry.icon_data.is_some() {
96 return false;
97 }
98
99 if let Some(path) = entry.icon_path.as_ref() {
100 if let Some(icon_data) = icon_data_from_path(path) {
101 entry.icon_data = Some(icon_data);
102 return true;
103 }
104 }
105
106 let icon_name = match entry.icon_name.as_deref() {
107 Some(name) => name,
108 None => return false,
109 };
110
111 let path = match lookup(icon_name)
112 .with_size(DEFAULT_ICON_SIZE)
113 .with_cache()
114 .find()
115 {
116 Some(path) => path,
117 None => {
118 entry.icon_path = Some(PathBuf::new());
119 return true;
120 }
121 };
122
123 entry.icon_path = Some(path.clone());
124 entry.icon_data = icon_data_from_path(&path);
125 entry.icon_data.is_some()
126}
127
128impl CachedAppDescriptor {
129 fn normalize(mut self) -> Self {
130 if self.lower_title.is_empty() {
131 self.lower_title = self.title.to_lowercase();
132 }
133 self
134 }
135
136 fn from_app_descriptor(
137 app: AppDescriptor,
138 cached_icon: Option<CachedIcon>,
139 ) -> CachedAppDescriptor {
140 let icon_data = cached_icon.or_else(|| {
141 app.icon_path
142 .as_ref()
143 .and_then(|path| icon_data_from_path(path))
144 });
145
146 CachedAppDescriptor {
147 appid: app.appid,
148 title: app.title,
149 lower_title: app.lower_title,
150 exec: app.exec,
151 exec_count: app.exec_count,
152 icon_name: app.icon_name,
153 icon_path: app.icon_path,
154 icon_data,
155 }
156 .normalize()
157 }
158
159 fn into_app_descriptor(self) -> AppDescriptor {
160 let lower_title = if self.lower_title.is_empty() {
161 self.title.to_lowercase()
162 } else {
163 self.lower_title
164 };
165 let icon_handle = if let Some(ref data) = self.icon_data {
166 icon_handle_from_data(data)
167 } else if self
168 .icon_path
169 .as_ref()
170 .map(|path| is_empty_path(path))
171 .unwrap_or(false)
172 {
173 FALLBACK_ICON_HANDLE.clone()
174 } else {
175 IconHandle::NotLoaded
176 };
177
178 AppDescriptor {
179 appid: self.appid,
180 title: self.title,
181 lower_title,
182 exec: self.exec,
183 exec_count: self.exec_count,
184 icon_name: self.icon_name,
185 icon_path: self.icon_path,
186 icon_handle,
187 }
188 }
189}
190
191impl Cache {
192 pub fn new(apps_loader: fn() -> Vec<AppDescriptor>) -> Self {
194 let path = resolve_db_file_path();
195 let config = Config::new().path(path);
196 let db = config.open().unwrap();
197
198 Cache { apps_loader, db }
199 }
200
201 pub fn is_empty(&self) -> bool {
202 self.db.is_empty()
203 }
204
205 pub fn read_all(&mut self) -> Option<Vec<AppDescriptor>> {
207 let entries = self.read_cached_entries()?;
208 if !entries.is_empty() || !self.db.is_empty() {
209 return Some(
210 entries
211 .into_iter()
212 .map(CachedAppDescriptor::into_app_descriptor)
213 .collect(),
214 );
215 }
216
217 let apps = (self.apps_loader)();
218 if apps.is_empty() {
219 return Some(Vec::new());
220 }
221 if self.build_snapshot_with_icons(&apps).is_ok() {
222 let entries = self.read_cached_entries()?;
223 return Some(
224 entries
225 .into_iter()
226 .map(CachedAppDescriptor::into_app_descriptor)
227 .collect(),
228 );
229 }
230
231 Some(apps)
232 }
233
234 pub fn read_top(&mut self, count: usize) -> Option<Vec<AppDescriptor>> {
236 let entries = self.read_cached_entries_top(count)?;
237 if !entries.is_empty() || !self.db.is_empty() {
238 return Some(
239 entries
240 .into_iter()
241 .map(CachedAppDescriptor::into_app_descriptor)
242 .collect(),
243 );
244 }
245
246 if count == 0 {
247 return Some(Vec::new());
248 }
249
250 let apps = (self.apps_loader)();
251 if apps.is_empty() {
252 return Some(Vec::new());
253 }
254 if self.build_snapshot_with_icons(&apps).is_ok() {
255 let entries = self.read_cached_entries_top(count)?;
256 return Some(
257 entries
258 .into_iter()
259 .map(CachedAppDescriptor::into_app_descriptor)
260 .collect(),
261 );
262 }
263
264 Some(apps.into_iter().take(count).collect())
265 }
266
267 pub fn refresh(&mut self) -> anyhow::Result<()> {
268 self.update_from_loader(None)
269 }
270
271 pub fn load_from_apps_loader(&mut self) -> Vec<AppDescriptor> {
273 self.read_all().unwrap_or_else(|| {
274 let apps = (self.apps_loader)();
275 let _ = self.build_snapshot_with_icons(&apps);
276 apps
277 })
278 }
279
280 fn write_snapshot(
281 &mut self,
282 apps: impl IntoIterator<Item = CachedAppDescriptor>,
283 ) -> anyhow::Result<()> {
284 let mut snapshot: Vec<CachedAppDescriptor> = apps.into_iter().collect();
285 snapshot.sort_by(|a, b| (b.exec_count, &a.title).cmp(&(a.exec_count, &b.title)));
286
287 self.db.clear()?;
288 for (count, app_descriptor) in snapshot.into_iter().enumerate() {
289 let encoded: Vec<u8> = bincode::serialize(&app_descriptor)?;
290 self.db.insert(count.to_be_bytes(), IVec::from(encoded))?;
291 }
292 self.db.flush()?;
293 Ok(())
294 }
295
296 fn update_from_loader(&mut self, selected_appid: Option<&str>) -> anyhow::Result<()> {
297 let latest_entries = (self.apps_loader)();
299 let cached_entries = self.read_cached_entries().unwrap_or_default();
300 let mut cached_by_id: HashMap<String, CachedAppDescriptor> = cached_entries
301 .into_iter()
302 .map(|entry| (entry.appid.clone(), entry))
303 .collect();
304
305 let mut updated_entry_wrappers: Vec<CachedAppDescriptor> =
307 Vec::with_capacity(latest_entries.len());
308
309 for mut latest_entry in latest_entries {
310 let cached_entry = cached_by_id.remove(&latest_entry.appid);
311 let (count, cached_icon_path, cached_icon_data) = if let Some(entry) = cached_entry {
312 (entry.exec_count, entry.icon_path, entry.icon_data)
313 } else {
314 (0, None, None)
315 };
316
317 let is_selected = selected_appid == Some(latest_entry.appid.as_str());
318 latest_entry.exec_count = if is_selected { count + 1 } else { count };
319 latest_entry.icon_path = cached_icon_path.or(latest_entry.icon_path);
320
321 updated_entry_wrappers.push(CachedAppDescriptor::from_app_descriptor(
322 latest_entry,
323 cached_icon_data,
324 ));
325 }
326
327 self.write_snapshot(updated_entry_wrappers)
329 }
330
331 pub fn update(&mut self, selected_app: &AppDescriptor) -> anyhow::Result<()> {
334 self.update_from_loader(Some(selected_app.appid.as_str()))
335 }
336
337 pub fn store_snapshot(&mut self, apps: &[AppDescriptor]) -> anyhow::Result<()> {
339 let cached_icons: HashMap<String, Option<CachedIcon>> = self
340 .read_cached_entries()
341 .unwrap_or_default()
342 .into_iter()
343 .map(|entry| (entry.appid, entry.icon_data))
344 .collect();
345
346 let snapshot = apps.iter().cloned().map(|app| {
347 let cached_icon = cached_icons.get(&app.appid).cloned().flatten();
348 CachedAppDescriptor::from_app_descriptor(app, cached_icon)
349 });
350
351 self.write_snapshot(snapshot)
352 }
353
354 fn read_cached_entries(&self) -> Option<Vec<CachedAppDescriptor>> {
355 let iter = self.db.range(SCAN_KEY..);
356
357 let mut app_descriptors: Vec<CachedAppDescriptor> = vec![];
358 for item in iter {
359 let (_key, desc_ivec) = item.ok()?;
360
361 let mut cached: CachedAppDescriptor =
362 match bincode::deserialize::<CachedAppDescriptor>(&desc_ivec[..]) {
363 Ok(entry) => entry,
364 Err(_) => {
365 let app: AppDescriptor = bincode::deserialize(&desc_ivec[..]).ok()?;
366 CachedAppDescriptor::from_app_descriptor(app, None)
367 }
368 };
369
370 cached = cached.normalize();
371 app_descriptors.push(cached);
372 }
373
374 Some(app_descriptors)
375 }
376
377 fn read_cached_entries_top(&self, count: usize) -> Option<Vec<CachedAppDescriptor>> {
378 let iter = self.db.range(SCAN_KEY..);
379 let mut app_descriptors: Vec<CachedAppDescriptor> = Vec::with_capacity(count);
380 for item in iter.take(count) {
381 let (_key, desc_ivec) = item.ok()?;
382
383 let mut cached: CachedAppDescriptor =
384 match bincode::deserialize::<CachedAppDescriptor>(&desc_ivec[..]) {
385 Ok(entry) => entry,
386 Err(_) => {
387 let app: AppDescriptor = bincode::deserialize(&desc_ivec[..]).ok()?;
388 CachedAppDescriptor::from_app_descriptor(app, None)
389 }
390 };
391
392 cached = cached.normalize();
393 app_descriptors.push(cached);
394 }
395
396 Some(app_descriptors)
397 }
398
399 pub fn build_snapshot_with_icons(&mut self, apps: &[AppDescriptor]) -> anyhow::Result<()> {
400 let snapshot = apps.iter().cloned().map(|app| {
401 let mut cached = CachedAppDescriptor::from_app_descriptor(app, None);
402 populate_icon_data(&mut cached);
403 cached
404 });
405
406 self.write_snapshot(snapshot)
407 }
408}
409
410fn resolve_db_file_path() -> PathBuf {
411 let mut path = dirs::cache_dir().unwrap();
412 path.push(format!("{}-{}", CACHE_NAMESPACE, env!("CARGO_PKG_VERSION")));
413 path
414}
415
416pub fn delete_cache_dir() -> std::io::Result<()> {
418 let path = resolve_db_file_path();
419 if path.exists() {
420 std::fs::remove_dir_all(path)?;
421 }
422 Ok(())
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use image::ImageEncoder;
429 use std::sync::{LazyLock, Mutex, OnceLock};
430
431 static LOADER_APPS: LazyLock<Mutex<Vec<AppDescriptor>>> =
432 LazyLock::new(|| Mutex::new(Vec::new()));
433 static CACHE_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
434
435 fn set_test_cache_home() -> PathBuf {
436 static CACHE_HOME: OnceLock<PathBuf> = OnceLock::new();
437 let cache_dir = CACHE_HOME.get_or_init(|| {
438 let mut dir = std::env::temp_dir();
439 dir.push(format!("elbey-cache-test-{}", std::process::id()));
440 let _ = std::fs::create_dir_all(&dir);
441 dir
442 });
443 std::env::set_var("XDG_CACHE_HOME", cache_dir);
444 cache_dir.clone()
445 }
446
447 fn prepare_test_cache() -> std::sync::MutexGuard<'static, ()> {
448 let guard = CACHE_LOCK.lock().expect("lock cache tests");
449 set_test_cache_home();
450 let path = resolve_db_file_path();
451 if path.exists() {
452 let _ = std::fs::remove_dir_all(path);
453 }
454 guard
455 }
456
457 fn empty_loader() -> Vec<AppDescriptor> {
458 Vec::new()
459 }
460
461 fn shared_loader() -> Vec<AppDescriptor> {
462 LOADER_APPS.lock().expect("lock loader apps").clone()
463 }
464
465 fn make_app(
466 appid: &str,
467 title: &str,
468 exec_count: usize,
469 icon_path: Option<PathBuf>,
470 ) -> AppDescriptor {
471 AppDescriptor {
472 appid: appid.to_string(),
473 title: title.to_string(),
474 lower_title: title.to_lowercase(),
475 exec: "/bin/true".to_string(),
476 exec_count,
477 icon_name: None,
478 icon_path,
479 icon_handle: IconHandle::NotLoaded,
480 }
481 }
482
483 #[test]
484 fn test_cache_reads_icons_as_rgba() {
485 let _guard = prepare_test_cache();
486 let cache_home = set_test_cache_home();
487 let icon_path = cache_home.join("test-icon.png");
488 let mut png_bytes = Vec::new();
489 let image = image::RgbaImage::from_pixel(1, 1, image::Rgba([255, 0, 0, 255]));
490 let encoder = image::codecs::png::PngEncoder::new(&mut png_bytes);
491 encoder
492 .write_image(
493 image.as_raw(),
494 image.width(),
495 image.height(),
496 image::ExtendedColorType::Rgba8,
497 )
498 .expect("encode test icon");
499 std::fs::write(&icon_path, &png_bytes).expect("write test icon");
500
501 let mut cache = Cache::new(Vec::new);
502 let app = AppDescriptor {
503 appid: "test-app".to_string(),
504 title: "Test App".to_string(),
505 lower_title: "test app".to_string(),
506 exec: "/bin/true".to_string(),
507 exec_count: 0,
508 icon_name: None,
509 icon_path: Some(icon_path),
510 icon_handle: IconHandle::NotLoaded,
511 };
512
513 cache.store_snapshot(&[app]).expect("store snapshot");
514 let apps = cache.read_all().expect("read snapshot");
515
516 assert!(matches!(apps[0].icon_handle, IconHandle::Raster(_)));
517 }
518
519 #[test]
520 fn test_write_snapshot_sorts_by_count_then_title() {
521 let _guard = prepare_test_cache();
522 let mut cache = Cache::new(empty_loader);
523 let apps = vec![
524 make_app("app-1", "Zoo", 5, None),
525 make_app("app-2", "Alpha", 5, None),
526 make_app("app-3", "Beta", 2, None),
527 ];
528
529 cache.store_snapshot(&apps).expect("store snapshot");
530 let apps = cache.read_all().expect("read snapshot");
531
532 let titles: Vec<&str> = apps.iter().map(|app| app.title.as_str()).collect();
533 assert_eq!(titles, vec!["Alpha", "Zoo", "Beta"]);
534 }
535
536 #[test]
537 fn test_refresh_preserves_count_and_cached_icon_data() {
538 let _guard = prepare_test_cache();
539 let cache_home = set_test_cache_home();
540 let icon_path = cache_home.join("test-refresh-icon.png");
541 let mut png_bytes = Vec::new();
542 let image = image::RgbaImage::from_pixel(1, 1, image::Rgba([0, 255, 0, 255]));
543 let encoder = image::codecs::png::PngEncoder::new(&mut png_bytes);
544 encoder
545 .write_image(
546 image.as_raw(),
547 image.width(),
548 image.height(),
549 image::ExtendedColorType::Rgba8,
550 )
551 .expect("encode refresh icon");
552 std::fs::write(&icon_path, &png_bytes).expect("write refresh icon");
553
554 let mut cache = Cache::new(shared_loader);
555 let initial_app = make_app("app-1", "Cached App", 3, Some(icon_path.clone()));
556 cache
557 .build_snapshot_with_icons(&[initial_app.clone()])
558 .expect("seed cache");
559
560 let refreshed_app = AppDescriptor {
561 icon_path: None,
562 exec_count: 0,
563 ..initial_app
564 };
565 *LOADER_APPS.lock().expect("lock loader apps") = vec![refreshed_app];
566
567 cache.refresh().expect("refresh cache");
568 let apps = cache.read_all().expect("read snapshot");
569
570 assert_eq!(apps[0].exec_count, 3);
571 assert_eq!(apps[0].icon_path.as_ref(), Some(&icon_path));
572 assert!(matches!(apps[0].icon_handle, IconHandle::Raster(_)));
573 }
574
575 #[test]
576 fn test_refresh_drops_missing_apps() {
577 let _guard = prepare_test_cache();
578 let mut cache = Cache::new(shared_loader);
579 let apps = vec![
580 make_app("app-1", "Keep", 1, None),
581 make_app("app-2", "Drop", 2, None),
582 ];
583 cache.store_snapshot(&apps).expect("store snapshot");
584
585 *LOADER_APPS.lock().expect("lock loader apps") = vec![make_app("app-1", "Keep", 0, None)];
586 cache.refresh().expect("refresh cache");
587
588 let apps = cache.read_all().expect("read snapshot");
589 assert_eq!(apps.len(), 1);
590 assert_eq!(apps[0].appid, "app-1");
591 }
592
593 #[test]
594 fn test_legacy_decode_normalizes_titles() {
595 let _guard = prepare_test_cache();
596 let mut cache = Cache::new(empty_loader);
597 let app = AppDescriptor {
598 appid: "legacy-app".to_string(),
599 title: "Legacy App".to_string(),
600 lower_title: String::new(),
601 exec: "/bin/true".to_string(),
602 exec_count: 1,
603 icon_name: None,
604 icon_path: None,
605 icon_handle: IconHandle::NotLoaded,
606 };
607 let encoded = bincode::serialize(&app).expect("serialize legacy app");
608 cache
609 .db
610 .insert(0_u32.to_be_bytes(), IVec::from(encoded))
611 .expect("insert legacy entry");
612 cache.db.flush().expect("flush legacy entry");
613
614 let apps = cache.read_all().expect("read snapshot");
615 assert_eq!(apps[0].lower_title, "legacy app");
616 assert!(matches!(apps[0].icon_handle, IconHandle::NotLoaded));
617 }
618
619 #[test]
620 fn test_read_all_populates_cache_on_first_run() {
621 let _guard = prepare_test_cache();
622 *LOADER_APPS.lock().expect("lock loader apps") =
623 vec![make_app("app-1", "First Run", 0, None)];
624
625 let mut cache = Cache::new(shared_loader);
626 let apps = cache.read_all().expect("read snapshot");
627
628 assert_eq!(apps.len(), 1);
629 assert_eq!(apps[0].appid, "app-1");
630 assert!(!cache.is_empty());
631 }
632}