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
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
40/// Tracks state to sort apps by usage and persist cached metadata.
41pub 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    /// Create a cache using the crate name as the cache namespace.
191    pub fn new(apps_loader: fn() -> Vec<AppDescriptor>) -> Self {
192        Self::new_with_namespace(apps_loader, env!("CARGO_PKG_NAME"))
193    }
194
195    /// Create a cache using a custom namespace for the cache directory.
196    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    /// Load all cached entries into app descriptors, if available.
213    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    /// Load up to `count` cached entries into app descriptors, if available.
242    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    /// Load from cache when present, falling back to the loader and populating icons.
279    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        // load data
305        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        // create new wrapper vec
313        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        // sort
335        self.write_snapshot(updated_entry_wrappers)
336    }
337
338    // Update the cache from local system and update usage stat
339    /// Refresh from the loader and increment usage for the selected app.
340    pub fn update(&mut self, selected_app: &AppDescriptor) -> anyhow::Result<()> {
341        self.update_from_loader(Some(selected_app.appid.as_str()))
342    }
343
344    /// Store a snapshot of apps, reusing cached icon data when possible.
345    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
423/// Remove the cache directory for the default namespace.
424pub fn delete_cache_dir() -> std::io::Result<()> {
425    delete_cache_dir_with_namespace(env!("CARGO_PKG_NAME"))
426}
427
428/// Remove the cache directory for a specific namespace.
429pub 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}