elbey_cache/
cache.rs

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
42/// Tracks state to sort apps by usage and persist cached metadata.
43pub 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    /// Create a cache using the default Elbey cache namespace.
193    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    /// Load all cached entries into app descriptors, if available.
206    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    /// Load up to `count` cached entries into app descriptors, if available.
235    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    /// Load from cache when present, falling back to the loader and populating icons.
272    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        // load data
298        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        // create new wrapper vec
306        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        // sort
328        self.write_snapshot(updated_entry_wrappers)
329    }
330
331    // Update the cache from local system and update usage stat
332    /// Refresh from the loader and increment usage for the selected app.
333    pub fn update(&mut self, selected_app: &AppDescriptor) -> anyhow::Result<()> {
334        self.update_from_loader(Some(selected_app.appid.as_str()))
335    }
336
337    /// Store a snapshot of apps, reusing cached icon data when possible.
338    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
416/// Remove the cache directory for the default namespace.
417pub 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}