Skip to main content

applications/
service.rs

1use crate::locale::SystemLocale;
2use crate::private_prelude::*;
3use ignis_events::Event;
4use notify::{EventKind, INotifyWatcher, RecursiveMode, Watcher};
5use nucleo_matcher::{Config, Matcher, Utf32Str};
6use std::sync::{Mutex, mpsc};
7use std::thread;
8use std::{collections::HashMap, env, fs, path::PathBuf, sync::RwLock};
9
10pub(crate) struct ApplicationServiceInner {
11    applications: RwLock<HashMap<String, Arc<DesktopApp>>>,
12    watcher: RwLock<Option<INotifyWatcher>>,
13    app_dirs: Vec<PathBuf>,
14    pub(crate) locale: SystemLocale,
15    matcher: Mutex<Matcher>,
16}
17
18/// A service to access desktop applications.
19#[derive(Clone)]
20pub struct ApplicationService {
21    pub(crate) inner: Arc<ApplicationServiceInner>,
22
23    /// Emitted when the list of installed applications changed.
24    ///
25    /// This can occur, for example, when an application is installed or removed from the system.
26    pub on_apps_refreshed: Event<()>,
27}
28
29impl ApplicationService {
30    /// Creates a new instance.
31    ///
32    /// It loads all application entries from `XDG_DATA_DIRS` and gets the system locale.
33    pub fn new() -> Self {
34        Self::new_with_env(
35            env::var_os("XDG_DATA_DIRS")
36                .into_iter()
37                .flat_map(|value| env::split_paths(&value).collect::<Vec<_>>())
38                .map(|path| path.join("applications"))
39                .collect(),
40            ["LC_MESSAGES", "LC_ALL", "LANG"]
41                .iter()
42                .find_map(|name| env::var(name).ok())
43                .unwrap_or_default()
44                .as_ref(),
45        )
46    }
47
48    pub(crate) fn new_with_env(app_dirs: Vec<PathBuf>, locale_string: &str) -> Self {
49        Self {
50            inner: Arc::new(ApplicationServiceInner {
51                applications: RwLock::new(Self::init_apps(&app_dirs)),
52                watcher: RwLock::new(None),
53                app_dirs,
54                locale: SystemLocale::new(locale_string),
55                matcher: Mutex::new(Matcher::new(Config::DEFAULT)),
56            }),
57            on_apps_refreshed: Event::new(),
58        }
59    }
60
61    /// Starts watching for changes in application entries. Re-initializes apps if a change occurs.
62    ///
63    /// # Errors
64    /// `Error::NotifyError`
65    pub fn watch(&self) -> Result<()> {
66        let (tx, rx) = mpsc::channel();
67
68        let mut watcher = notify::recommended_watcher(tx)?;
69
70        for path in &self.inner.app_dirs {
71            if path.exists() {
72                watcher.watch(path, RecursiveMode::NonRecursive)?;
73            };
74        }
75
76        *self.inner.watcher.write().unwrap() = Some(watcher);
77
78        let service = self.clone();
79
80        thread::spawn(move || {
81            fn refresh(service: ApplicationService) {
82                *service.inner.applications.write().unwrap() =
83                    ApplicationService::init_apps(&service.inner.app_dirs);
84
85                service.on_apps_refreshed.emit(&());
86
87                tracing::debug!("Apps are refreshed");
88            }
89
90            for res in rx {
91                let service = service.clone();
92                match res {
93                    Ok(event) => {
94                        if let EventKind::Modify(_) = event.kind {
95                            refresh(service)
96                        }
97                    }
98                    Err(e) => {
99                        tracing::warn!("Watch error: {}", e)
100                    }
101                }
102            }
103        });
104
105        Ok(())
106    }
107
108    fn init_apps(app_dirs: &[PathBuf]) -> HashMap<String, Arc<DesktopApp>> {
109        app_dirs
110            .iter()
111            .filter_map(|dir| fs::read_dir(dir).ok())
112            .flatten()
113            .filter_map(|entry| entry.ok())
114            .filter(|entry| entry.file_name().to_string_lossy().ends_with(".desktop"))
115            .filter_map(|entry| {
116                DesktopApp::new(
117                    entry.file_name().to_string_lossy().replace(".desktop", ""),
118                    fs::read_to_string(entry.path()).ok()?,
119                )
120            })
121            .map(|app| (app.app_id.clone(), Arc::new(app)))
122            .fold(HashMap::new(), |mut map, (app_id, app)| {
123                // use the first appearance of the desktop file
124                map.entry(app_id).or_insert(app);
125                map
126            })
127    }
128
129    /// Returns a list of applications.
130    pub fn apps(&self) -> Vec<DesktopAppHandle> {
131        self.inner
132            .applications
133            .read()
134            .unwrap()
135            .values()
136            .map(|app| DesktopAppHandle {
137                inner: app.clone(),
138                service: self.clone(),
139            })
140            .collect()
141    }
142
143    /// Returns an application by its ID, or `None` if it is not found.
144    pub fn app_by_id(&self, app_id: &str) -> Option<DesktopAppHandle> {
145        self.inner
146            .applications
147            .read()
148            .unwrap()
149            .get(app_id)
150            .map(|app| DesktopAppHandle {
151                inner: app.clone(),
152                service: self.clone(),
153            })
154    }
155
156    /// Fuzzily search through the application entries by provided application name.
157    pub fn search_by_name(&self, query: &str) -> Vec<DesktopAppHandle> {
158        let mut query_buf = Vec::new();
159        let needle = Utf32Str::new(query, &mut query_buf);
160
161        let mut results = Vec::new();
162
163        for app in self.apps() {
164            let mut app_buf = Vec::new();
165            let name = app.name();
166            let haystack = Utf32Str::new(&name, &mut app_buf);
167
168            if let Some(score) = self
169                .inner
170                .matcher
171                .lock()
172                .unwrap()
173                .fuzzy_match(haystack, needle)
174            {
175                results.push((app, score))
176            }
177        }
178
179        results.sort_unstable_by_key(|(_, score)| std::cmp::Reverse(*score));
180
181        results.into_iter().map(|(handle, _)| handle).collect()
182    }
183}
184
185impl Default for ApplicationService {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use std::time::Duration;
194
195    use tempfile::TempDir;
196
197    use super::*;
198    use fake::Fake;
199    use fake::faker::lorem::en::Sentence;
200    use tracing_subscriber::EnvFilter;
201    use uuid::Uuid;
202
203    struct TestContext {
204        tmp_dir: TempDir,
205        apps_dir: PathBuf,
206    }
207
208    impl TestContext {
209        fn new() -> Self {
210            tracing_subscriber::fmt()
211                .with_env_filter(EnvFilter::new("debug"))
212                .try_init()
213                .ok();
214
215            let mut tmp_dir = TempDir::new().unwrap();
216
217            if std::env::var_os("NO_TMP_CLEANUP")
218                .unwrap_or_default()
219                .to_string_lossy()
220                == "1"
221            {
222                tmp_dir.disable_cleanup(true);
223            }
224
225            let apps_dir = tmp_dir.path().to_owned().join("applications");
226            std::fs::create_dir(&apps_dir).unwrap();
227
228            Self { tmp_dir, apps_dir }
229        }
230
231        fn add_random_entry(&self, application_type: &str, no_display: bool) {
232            let name: String = Sentence(1..4).fake();
233            let app_id = Uuid::new_v4().to_string();
234
235            let contents = format!(
236                r#"[Desktop Entry]
237Name={}
238Type={}
239NoDisplay={}
240                "#,
241                name, application_type, no_display
242            );
243
244            std::fs::write(self.apps_dir.join(format!("{}.desktop", app_id)), contents).unwrap();
245        }
246
247        fn add_app_entry(&self, name: &str) {
248            let app_id = Uuid::new_v4().to_string();
249            let contents = format!(
250                r#"[Desktop Entry]
251Name={}
252Type=Application
253                "#,
254                name
255            );
256
257            std::fs::write(self.apps_dir.join(format!("{}.desktop", app_id)), contents).unwrap();
258        }
259
260        fn init_service(&self) -> ApplicationService {
261            ApplicationService::new_with_env(
262                vec![self.tmp_dir.path().to_owned().join("applications")],
263                "",
264            )
265        }
266    }
267
268    #[test]
269    fn test_new() {
270        let ctx = TestContext::new();
271        let service = ctx.init_service();
272
273        assert_eq!(service.apps().len(), 0);
274    }
275
276    #[test]
277    fn test_apps() {
278        let ctx = TestContext::new();
279        for _ in 0..10 {
280            ctx.add_random_entry("Application", false);
281        }
282
283        for _ in 0..5 {
284            ctx.add_random_entry("Link", false);
285        }
286
287        ctx.add_random_entry("Application", true);
288
289        let service = ctx.init_service();
290
291        assert_eq!(service.apps().len(), 10);
292    }
293
294    #[test]
295    fn test_watch() {
296        let ctx = TestContext::new();
297        ctx.add_random_entry("Application", false);
298        let service = ctx.init_service();
299
300        assert_eq!(service.apps().len(), 1);
301
302        service.watch().unwrap();
303        ctx.add_random_entry("Application", false);
304
305        thread::sleep(Duration::from_millis(500));
306        assert_eq!(service.apps().len(), 2);
307    }
308
309    #[test]
310    fn test_search() {
311        let ctx = TestContext::new();
312        ctx.add_app_entry("Firefox");
313        ctx.add_app_entry("Steam");
314        ctx.add_app_entry("Ignis");
315
316        let service = ctx.init_service();
317
318        assert_eq!(
319            service.search_by_name("Firefox").get(0).unwrap().name(),
320            "Firefox"
321        );
322
323        assert_eq!(
324            service.search_by_name("fire").get(0).unwrap().name(),
325            "Firefox"
326        );
327
328        assert_eq!(service.search_by_name("sm").get(0).unwrap().name(), "Steam");
329
330        assert_eq!(
331            service.search_by_name("igns").get(0).unwrap().name(),
332            "Ignis"
333        );
334    }
335
336    #[test]
337    fn test_on_apps_refreshed() {
338        let received = Arc::new(Mutex::new(false));
339
340        let ctx = TestContext::new();
341        let service = ctx.init_service();
342        service.watch().unwrap();
343
344        let received_clone = received.clone();
345
346        service.on_apps_refreshed.connect(move |_| {
347            *received_clone.lock().unwrap() = true;
348        });
349
350        ctx.add_app_entry("Asd");
351
352        std::thread::sleep(std::time::Duration::from_secs(1));
353
354        assert_eq!(*received.lock().unwrap(), true);
355    }
356}