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