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::{Batch, 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        let mut batch = Batch::default();
288        for item in self.db.range(SCAN_KEY..) {
289            let (key, _value) = item?;
290            batch.remove(key);
291        }
292        for (count, app_descriptor) in snapshot.into_iter().enumerate() {
293            let encoded: Vec<u8> = bincode::serialize(&app_descriptor)?;
294            batch.insert(count.to_be_bytes().to_vec(), IVec::from(encoded));
295        }
296        self.db.apply_batch(batch)?;
297        self.db.flush()?;
298        Ok(())
299    }
300
301    fn update_from_loader(&mut self, selected_appid: Option<&str>) -> anyhow::Result<()> {
302        // load data
303        let latest_entries = (self.apps_loader)();
304        let cached_entries = self.read_cached_entries().unwrap_or_default();
305        let mut cached_by_id: HashMap<String, CachedAppDescriptor> = cached_entries
306            .into_iter()
307            .map(|entry| (entry.appid.clone(), entry))
308            .collect();
309
310        // create new wrapper vec
311        let mut updated_entry_wrappers: Vec<CachedAppDescriptor> =
312            Vec::with_capacity(latest_entries.len());
313
314        for mut latest_entry in latest_entries {
315            let cached_entry = cached_by_id.remove(&latest_entry.appid);
316            let (count, cached_icon_path, cached_icon_data) = if let Some(entry) = cached_entry {
317                (entry.exec_count, entry.icon_path, entry.icon_data)
318            } else {
319                (0, None, None)
320            };
321
322            let is_selected = selected_appid == Some(latest_entry.appid.as_str());
323            latest_entry.exec_count = if is_selected { count + 1 } else { count };
324            latest_entry.icon_path = cached_icon_path.or(latest_entry.icon_path);
325
326            updated_entry_wrappers.push(CachedAppDescriptor::from_app_descriptor(
327                latest_entry,
328                cached_icon_data,
329            ));
330        }
331
332        // sort
333        self.write_snapshot(updated_entry_wrappers)
334    }
335
336    // Update the cache from local system and update usage stat
337    /// Refresh from the loader and increment usage for the selected app.
338    pub fn update(&mut self, selected_app: &AppDescriptor) -> anyhow::Result<()> {
339        self.update_from_loader(Some(selected_app.appid.as_str()))
340    }
341
342    /// Store a snapshot of apps, reusing cached icon data when possible.
343    pub fn store_snapshot(&mut self, apps: &[AppDescriptor]) -> anyhow::Result<()> {
344        let cached_icons: HashMap<String, Option<CachedIcon>> = self
345            .read_cached_entries()
346            .unwrap_or_default()
347            .into_iter()
348            .map(|entry| (entry.appid, entry.icon_data))
349            .collect();
350
351        let snapshot = apps.iter().cloned().map(|app| {
352            let cached_icon = cached_icons.get(&app.appid).cloned().flatten();
353            CachedAppDescriptor::from_app_descriptor(app, cached_icon)
354        });
355
356        self.write_snapshot(snapshot)
357    }
358
359    fn read_cached_entries(&self) -> Option<Vec<CachedAppDescriptor>> {
360        let iter = self.db.range(SCAN_KEY..);
361
362        let mut app_descriptors: Vec<CachedAppDescriptor> = vec![];
363        for item in iter {
364            let (_key, desc_ivec) = item.ok()?;
365
366            let mut cached: CachedAppDescriptor =
367                match bincode::deserialize::<CachedAppDescriptor>(&desc_ivec[..]) {
368                    Ok(entry) => entry,
369                    Err(_) => {
370                        let app: AppDescriptor = bincode::deserialize(&desc_ivec[..]).ok()?;
371                        CachedAppDescriptor::from_app_descriptor(app, None)
372                    }
373                };
374
375            cached = cached.normalize();
376            app_descriptors.push(cached);
377        }
378
379        Some(app_descriptors)
380    }
381
382    fn read_cached_entries_top(&self, count: usize) -> Option<Vec<CachedAppDescriptor>> {
383        let iter = self.db.range(SCAN_KEY..);
384        let mut app_descriptors: Vec<CachedAppDescriptor> = Vec::with_capacity(count);
385        for item in iter.take(count) {
386            let (_key, desc_ivec) = item.ok()?;
387
388            let mut cached: CachedAppDescriptor =
389                match bincode::deserialize::<CachedAppDescriptor>(&desc_ivec[..]) {
390                    Ok(entry) => entry,
391                    Err(_) => {
392                        let app: AppDescriptor = bincode::deserialize(&desc_ivec[..]).ok()?;
393                        CachedAppDescriptor::from_app_descriptor(app, None)
394                    }
395                };
396
397            cached = cached.normalize();
398            app_descriptors.push(cached);
399        }
400
401        Some(app_descriptors)
402    }
403
404    pub fn build_snapshot_with_icons(&mut self, apps: &[AppDescriptor]) -> anyhow::Result<()> {
405        let snapshot = apps.iter().cloned().map(|app| {
406            let mut cached = CachedAppDescriptor::from_app_descriptor(app, None);
407            populate_icon_data(&mut cached);
408            cached
409        });
410
411        self.write_snapshot(snapshot)
412    }
413}
414
415fn resolve_db_file_path() -> PathBuf {
416    let mut path = dirs::cache_dir().unwrap();
417    path.push(format!("{}-{}", CACHE_NAMESPACE, env!("CARGO_PKG_VERSION")));
418    path
419}
420
421/// Remove the cache directory for the default namespace.
422pub fn delete_cache_dir() -> std::io::Result<()> {
423    let path = resolve_db_file_path();
424    if path.exists() {
425        std::fs::remove_dir_all(path)?;
426    }
427    Ok(())
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use image::ImageEncoder;
434    use std::sync::{LazyLock, Mutex, OnceLock};
435
436    static LOADER_APPS: LazyLock<Mutex<Vec<AppDescriptor>>> =
437        LazyLock::new(|| Mutex::new(Vec::new()));
438    static CACHE_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
439
440    fn set_test_cache_home() -> PathBuf {
441        static CACHE_HOME: OnceLock<PathBuf> = OnceLock::new();
442        let cache_dir = CACHE_HOME.get_or_init(|| {
443            let mut dir = std::env::temp_dir();
444            dir.push(format!("elbey-cache-test-{}", std::process::id()));
445            let _ = std::fs::create_dir_all(&dir);
446            dir
447        });
448        std::env::set_var("XDG_CACHE_HOME", cache_dir);
449        cache_dir.clone()
450    }
451
452    fn prepare_test_cache() -> std::sync::MutexGuard<'static, ()> {
453        let guard = CACHE_LOCK.lock().expect("lock cache tests");
454        set_test_cache_home();
455        let path = resolve_db_file_path();
456        if path.exists() {
457            let _ = std::fs::remove_dir_all(path);
458        }
459        guard
460    }
461
462    fn empty_loader() -> Vec<AppDescriptor> {
463        Vec::new()
464    }
465
466    fn shared_loader() -> Vec<AppDescriptor> {
467        LOADER_APPS.lock().expect("lock loader apps").clone()
468    }
469
470    fn make_app(
471        appid: &str,
472        title: &str,
473        exec_count: usize,
474        icon_path: Option<PathBuf>,
475    ) -> AppDescriptor {
476        AppDescriptor {
477            appid: appid.to_string(),
478            title: title.to_string(),
479            lower_title: title.to_lowercase(),
480            exec: "/bin/true".to_string(),
481            exec_count,
482            icon_name: None,
483            icon_path,
484            icon_handle: IconHandle::NotLoaded,
485        }
486    }
487
488    #[test]
489    fn test_cache_reads_icons_as_rgba() {
490        let _guard = prepare_test_cache();
491        let cache_home = set_test_cache_home();
492        let icon_path = cache_home.join("test-icon.png");
493        let mut png_bytes = Vec::new();
494        let image = image::RgbaImage::from_pixel(1, 1, image::Rgba([255, 0, 0, 255]));
495        let encoder = image::codecs::png::PngEncoder::new(&mut png_bytes);
496        encoder
497            .write_image(
498                image.as_raw(),
499                image.width(),
500                image.height(),
501                image::ExtendedColorType::Rgba8,
502            )
503            .expect("encode test icon");
504        std::fs::write(&icon_path, &png_bytes).expect("write test icon");
505
506        let mut cache = Cache::new(Vec::new);
507        let app = AppDescriptor {
508            appid: "test-app".to_string(),
509            title: "Test App".to_string(),
510            lower_title: "test app".to_string(),
511            exec: "/bin/true".to_string(),
512            exec_count: 0,
513            icon_name: None,
514            icon_path: Some(icon_path),
515            icon_handle: IconHandle::NotLoaded,
516        };
517
518        cache.store_snapshot(&[app]).expect("store snapshot");
519        let apps = cache.read_all().expect("read snapshot");
520
521        assert!(matches!(apps[0].icon_handle, IconHandle::Raster(_)));
522    }
523
524    #[test]
525    fn test_write_snapshot_sorts_by_count_then_title() {
526        let _guard = prepare_test_cache();
527        let mut cache = Cache::new(empty_loader);
528        let apps = vec![
529            make_app("app-1", "Zoo", 5, None),
530            make_app("app-2", "Alpha", 5, None),
531            make_app("app-3", "Beta", 2, None),
532        ];
533
534        cache.store_snapshot(&apps).expect("store snapshot");
535        let apps = cache.read_all().expect("read snapshot");
536
537        let titles: Vec<&str> = apps.iter().map(|app| app.title.as_str()).collect();
538        assert_eq!(titles, vec!["Alpha", "Zoo", "Beta"]);
539    }
540
541    #[test]
542    fn test_refresh_preserves_count_and_cached_icon_data() {
543        let _guard = prepare_test_cache();
544        let cache_home = set_test_cache_home();
545        let icon_path = cache_home.join("test-refresh-icon.png");
546        let mut png_bytes = Vec::new();
547        let image = image::RgbaImage::from_pixel(1, 1, image::Rgba([0, 255, 0, 255]));
548        let encoder = image::codecs::png::PngEncoder::new(&mut png_bytes);
549        encoder
550            .write_image(
551                image.as_raw(),
552                image.width(),
553                image.height(),
554                image::ExtendedColorType::Rgba8,
555            )
556            .expect("encode refresh icon");
557        std::fs::write(&icon_path, &png_bytes).expect("write refresh icon");
558
559        let mut cache = Cache::new(shared_loader);
560        let initial_app = make_app("app-1", "Cached App", 3, Some(icon_path.clone()));
561        cache
562            .build_snapshot_with_icons(&[initial_app.clone()])
563            .expect("seed cache");
564
565        let refreshed_app = AppDescriptor {
566            icon_path: None,
567            exec_count: 0,
568            ..initial_app
569        };
570        *LOADER_APPS.lock().expect("lock loader apps") = vec![refreshed_app];
571
572        cache.refresh().expect("refresh cache");
573        let apps = cache.read_all().expect("read snapshot");
574
575        assert_eq!(apps[0].exec_count, 3);
576        assert_eq!(apps[0].icon_path.as_ref(), Some(&icon_path));
577        assert!(matches!(apps[0].icon_handle, IconHandle::Raster(_)));
578    }
579
580    #[test]
581    fn test_refresh_drops_missing_apps() {
582        let _guard = prepare_test_cache();
583        let mut cache = Cache::new(shared_loader);
584        let apps = vec![
585            make_app("app-1", "Keep", 1, None),
586            make_app("app-2", "Drop", 2, None),
587        ];
588        cache.store_snapshot(&apps).expect("store snapshot");
589
590        *LOADER_APPS.lock().expect("lock loader apps") = vec![make_app("app-1", "Keep", 0, None)];
591        cache.refresh().expect("refresh cache");
592
593        let apps = cache.read_all().expect("read snapshot");
594        assert_eq!(apps.len(), 1);
595        assert_eq!(apps[0].appid, "app-1");
596    }
597
598    #[test]
599    fn test_legacy_decode_normalizes_titles() {
600        let _guard = prepare_test_cache();
601        let mut cache = Cache::new(empty_loader);
602        let app = AppDescriptor {
603            appid: "legacy-app".to_string(),
604            title: "Legacy App".to_string(),
605            lower_title: String::new(),
606            exec: "/bin/true".to_string(),
607            exec_count: 1,
608            icon_name: None,
609            icon_path: None,
610            icon_handle: IconHandle::NotLoaded,
611        };
612        let encoded = bincode::serialize(&app).expect("serialize legacy app");
613        cache
614            .db
615            .insert(0_u32.to_be_bytes(), IVec::from(encoded))
616            .expect("insert legacy entry");
617        cache.db.flush().expect("flush legacy entry");
618
619        let apps = cache.read_all().expect("read snapshot");
620        assert_eq!(apps[0].lower_title, "legacy app");
621        assert!(matches!(apps[0].icon_handle, IconHandle::NotLoaded));
622    }
623
624    #[test]
625    fn test_read_all_populates_cache_on_first_run() {
626        let _guard = prepare_test_cache();
627        *LOADER_APPS.lock().expect("lock loader apps") =
628            vec![make_app("app-1", "First Run", 0, None)];
629
630        let mut cache = Cache::new(shared_loader);
631        let apps = cache.read_all().expect("read snapshot");
632
633        assert_eq!(apps.len(), 1);
634        assert_eq!(apps[0].appid, "app-1");
635        assert!(!cache.is_empty());
636    }
637}