Skip to main content

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::{
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
44/// Tracks state to sort apps by usage and persist cached metadata.
45pub 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    /// Create a cache using the default Elbey cache namespace.
195    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    /// Load all cached entries into app descriptors, if available.
204    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    /// Load up to `count` cached entries into app descriptors, if available.
233    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    /// Refresh the cache and patch an existing app list in-place, preserving icon handles
270    /// when the underlying icon identifiers are unchanged.
271    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    /// Refresh the cache in-place and return top apps with preserved icon handles.
291    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    /// Load from cache when present, falling back to the loader and populating icons.
313    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        // load data
344        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        // create new wrapper vec
352        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        // sort
374        self.write_snapshot(updated_entry_wrappers)
375    }
376
377    // Update the cache from local system and update usage stat
378    /// Refresh from the loader and increment usage for the selected app.
379    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    /// Store a snapshot of apps, reusing cached icon data when possible.
384    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
462/// Remove the cache directory for the default namespace.
463pub 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}