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