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#[derive(Clone)]
20pub struct ApplicationService {
21 pub(crate) inner: Arc<ApplicationServiceInner>,
22
23 pub on_apps_refreshed: Event<()>,
27}
28
29impl ApplicationService {
30 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 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 map.entry(app_id).or_insert(app);
125 map
126 })
127 }
128
129 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 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 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}