git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

src/pages/default_apps.rs (20K)

  1 //! XDG default applications: curated categories over `~/.config/mimeapps.list`.
  2 //! Candidates come from the installed `.desktop` entries that claim the
  3 //! category's MIME types; picks are applied through ONE `xdg-mime default`
  4 //! call naming every type, so a browser pick covers http/https/text-html at
  5 //! once (and atomically — parallel calls clobber each other's writes).
  6 //!
  7 //! The Terminal row is the one non-MIME category: terminals have no MIME type,
  8 //! so candidates come from entries declaring `Categories=TerminalEmulator`,
  9 //! and the pick is stored as a COMMAND in the shared config.kdl
 10 //! (`default_terminal`) — which the launcher reads to host `Terminal=true`
 11 //! entries, and startcce exports as `$TERMINAL` for everything else.
 12 
 13 use crate::app::{AppAction, PageContent};
 14 use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy};
 15 use cce_ui::widget::{Dropdown, WidgetHost};
 16 use std::collections::HashMap;
 17 use std::path::PathBuf;
 18 
 19 /// How a category resolves candidates, its current default, and a pick.
 20 #[derive(Debug, Clone, Copy, PartialEq)]
 21 pub enum CategoryKind {
 22     /// The first type is the one queried for the current default; a pick sets
 23     /// every type in the list via `xdg-mime default`.
 24     Mime(&'static [&'static str]),
 25     /// The DE's default terminal (see module docs).
 26     Terminal,
 27 }
 28 
 29 const CATEGORIES: &[(&str, CategoryKind)] = &[
 30     ("Web Browser", CategoryKind::Mime(&["x-scheme-handler/http", "x-scheme-handler/https", "text/html"])),
 31     ("Mail", CategoryKind::Mime(&["x-scheme-handler/mailto"])),
 32     ("File Manager", CategoryKind::Mime(&["inode/directory"])),
 33     ("Terminal", CategoryKind::Terminal),
 34     ("Text Editor", CategoryKind::Mime(&["text/plain"])),
 35     ("Images", CategoryKind::Mime(&["image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"])),
 36     ("Audio", CategoryKind::Mime(&["audio/mpeg", "audio/flac", "audio/ogg", "audio/x-wav"])),
 37     ("Video", CategoryKind::Mime(&["video/mp4", "video/x-matroska", "video/webm"])),
 38     ("PDF", CategoryKind::Mime(&["application/pdf"])),
 39 ];
 40 
 41 const NOT_SET: &str = "— not set —";
 42 
 43 /// One category's fetched facts: (desktop id, display name) candidates and the
 44 /// current default's desktop id. Plain data — the widget state lives in
 45 /// [`DefaultAppsState`].
 46 #[derive(Debug, Clone, PartialEq)]
 47 pub struct CategoryInfo {
 48     pub candidates: Vec<(String, String)>,
 49     pub current: Option<String>,
 50 }
 51 
 52 #[derive(Debug, Clone)]
 53 pub struct DefaultAppsInfo(pub Vec<CategoryInfo>);
 54 
 55 #[derive(Debug, Clone)]
 56 pub struct CategoryEntry {
 57     pub label: &'static str,
 58     pub kind: CategoryKind,
 59     pub info: CategoryInfo,
 60     /// Applied value per dropdown option (desktop id for MIME categories, a
 61     /// command for Terminal; None = the "not set" placeholder row).
 62     pub option_ids: Vec<Option<String>>,
 63     pub dropdown: cce_ui::widget::Adapted<Dropdown>,
 64 }
 65 
 66 #[derive(Debug, Clone)]
 67 pub struct DefaultAppsState {
 68     pub loaded: bool,
 69     pub categories: Vec<CategoryEntry>,
 70 }
 71 
 72 impl Default for DefaultAppsState {
 73     fn default() -> Self {
 74         Self {
 75             loaded: false,
 76             categories: CATEGORIES
 77                 .iter()
 78                 .map(|&(label, kind)| CategoryEntry {
 79                     label,
 80                     kind,
 81                     info: CategoryInfo { candidates: Vec::new(), current: None },
 82                     option_ids: vec![None],
 83                     dropdown: Dropdown::new(vec![NOT_SET.to_string()], 0).with_label(label),
 84                 })
 85                 .collect(),
 86         }
 87     }
 88 }
 89 
 90 #[derive(Debug, Clone)]
 91 pub enum DefaultAppsMessage {
 92     Refreshed(DefaultAppsInfo),
 93     /// The user picked dropdown option `1` in category `0`.
 94     Set(usize, usize),
 95 }
 96 
 97 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
 98 
 99 fn rebuild_entry_options(entry: &mut CategoryEntry) {
100     let mut options = Vec::new();
101     let mut ids = Vec::new();
102     let current_idx = entry
103         .info
104         .current
105         .as_ref()
106         .and_then(|cur| entry.info.candidates.iter().position(|(id, _)| id == cur));
107     if entry.info.current.is_none() || current_idx.is_none() {
108         options.push(NOT_SET.to_string());
109         ids.push(None);
110     }
111     for (id, name) in &entry.info.candidates {
112         options.push(name.clone());
113         ids.push(Some(id.clone()));
114     }
115     let selected = entry
116         .info
117         .current
118         .as_ref()
119         .and_then(|cur| ids.iter().position(|i| i.as_deref() == Some(cur)))
120         .unwrap_or(0);
121     entry.dropdown.options = options;
122     entry.dropdown.selected = selected;
123     entry.option_ids = ids;
124 }
125 
126 pub fn view(state: &mut DefaultAppsState, cx: f32, cy: f32, cw: f32, ch: f32, _root_focused: bool, sec_focused: &[bool], layout: &mut dyn LayoutStrategy, ctx: &mut cce_ui::context::UiContext) -> PageContent {
127     let mut final_pc = PageContent::new();
128     let sec_w = 320.0f32;
129     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
130 
131     builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
132         let sec_w = sec.cw;
133         if !state.loaded {
134             sec.text("Scanning installed applications...", 12.0, 0.0, 12.0, TEXT_DIM);
135         } else {
136             let mut stack = sec.vstack(cce_ui::layout::plate_gap());
137             for entry in state.categories.iter_mut() {
138                 entry.dropdown.set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
139                 stack.add_widget(&mut entry.dropdown, sec_w - 28.0, 44.0, ctx);
140             }
141         }
142     });
143 
144     final_pc
145 }
146 
147 pub fn update(state: &mut DefaultAppsState, msg: DefaultAppsMessage) {
148     match msg {
149         DefaultAppsMessage::Refreshed(info) => {
150             state.loaded = true;
151             for (entry, cat) in state.categories.iter_mut().zip(info.0.into_iter()) {
152                 // Leave an open dropdown alone — the next refresh normalizes it.
153                 if entry.info == cat || entry.dropdown.open {
154                     continue;
155                 }
156                 entry.info = cat;
157                 rebuild_entry_options(entry);
158             }
159         }
160         DefaultAppsMessage::Set(cat_idx, opt_idx) => {
161             log::info!("[default_apps] Set(cat={cat_idx}, opt={opt_idx})");
162             let Some(entry) = state.categories.get_mut(cat_idx) else { return };
163             let Some(Some(id)) = entry.option_ids.get(opt_idx).cloned() else { return };
164             log::info!("[default_apps] applying {:?} -> {id}", entry.label);
165             match entry.kind {
166                 CategoryKind::Mime(mimes) => {
167                     // ONE xdg-mime invocation for every type. Parallel
168                     // invocations race on the shared `mimeapps.list.new`
169                     // temp file and drop each other's writes: a browser pick
170                     // left text/html unset (falling through to whatever
171                     // mimeinfo.cache lists first), so Chrome's
172                     // `xdg-settings check default-web-browser` said "no"
173                     // while this page — reading only the first type — said
174                     // Chrome.
175                     let id = id.clone();
176                     tokio::spawn(async move {
177                         let out = tokio::process::Command::new("xdg-mime")
178                             .arg("default")
179                             .arg(&id)
180                             .args(mimes)
181                             .output()
182                             .await;
183                         match out {
184                             Ok(o) if o.status.success() => {}
185                             Ok(o) => log::error!(
186                                 "[default_apps] xdg-mime default {id} failed: {}",
187                                 String::from_utf8_lossy(&o.stderr).trim()
188                             ),
189                             Err(e) => log::error!("[default_apps] xdg-mime spawn failed: {e}"),
190                         }
191                     });
192                 }
193                 CategoryKind::Terminal => set_default_terminal(&id),
194             }
195             entry.info.current = Some(id);
196             rebuild_entry_options(entry);
197         }
198     }
199 }
200 
201 /// Write the terminal pick into the SHARED config.kdl (`default_terminal` at
202 /// the top level) — the launcher and startcce read it from there, so the
203 /// per-app override file would hide it from both.
204 fn set_default_terminal(cmd: &str) {
205     let path = cce_ui::config::get_config_path();
206     let content = std::fs::read_to_string(&path).unwrap_or_default();
207     let Ok(mut doc) = content.parse::<kdl::KdlDocument>() else {
208         log::error!("[default_apps] config.kdl did not parse; terminal pick dropped");
209         return;
210     };
211     if cce_ui::config::update_kdl_in_memory(&mut doc, "default_terminal", cmd, "") {
212         if let Err(e) = std::fs::write(&path, doc.to_string()) {
213             log::error!("[default_apps] config.kdl write failed: {e}");
214         }
215     } else {
216         log::error!("[default_apps] update_kdl_in_memory refused default_terminal");
217     }
218 }
219 
220 // ── Background fetching ──
221 
222 struct DesktopApp {
223     name: String,
224     mimes: Vec<String>,
225     /// `Categories=` entries (the Terminal row keys off `TerminalEmulator`).
226     categories: Vec<String>,
227     /// Basename of `Exec=`'s first token — the command a terminal pick stores.
228     exec_cmd: Option<String>,
229     no_display: bool,
230 }
231 
232 fn desktop_dirs() -> Vec<PathBuf> {
233     let mut dirs = Vec::new();
234     let data_home = std::env::var("XDG_DATA_HOME")
235         .ok()
236         .filter(|s| !s.is_empty())
237         .map(PathBuf::from)
238         .or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".local/share")));
239     if let Some(h) = data_home {
240         dirs.push(h.join("applications"));
241     }
242     let data_dirs = std::env::var("XDG_DATA_DIRS").ok().filter(|s| !s.is_empty())
243         .unwrap_or_else(|| "/usr/local/share:/usr/share".to_string());
244     for d in data_dirs.split(':').filter(|d| !d.is_empty()) {
245         dirs.push(PathBuf::from(d).join("applications"));
246     }
247     dirs
248 }
249 
250 /// Parse the `[Desktop Entry]` group of one `.desktop` file. Returns None for
251 /// non-applications and `Hidden=true` entries (spec: treated as nonexistent).
252 fn parse_desktop_file(path: &std::path::Path) -> Option<DesktopApp> {
253     let content = std::fs::read_to_string(path).ok()?;
254     let mut in_entry = false;
255     let mut name = None;
256     let mut mimes = Vec::new();
257     let mut categories = Vec::new();
258     let mut exec_cmd = None;
259     let mut app_type = None;
260     let mut hidden = false;
261     let mut no_display = false;
262     for line in content.lines() {
263         let line = line.trim();
264         if line.starts_with('[') {
265             in_entry = line == "[Desktop Entry]";
266             continue;
267         }
268         if !in_entry {
269             continue;
270         }
271         if let Some((k, v)) = line.split_once('=') {
272             match k.trim() {
273                 "Name" if name.is_none() => name = Some(v.trim().to_string()),
274                 "Type" => app_type = Some(v.trim().to_string()),
275                 "Hidden" => hidden = v.trim().eq_ignore_ascii_case("true"),
276                 "NoDisplay" => no_display = v.trim().eq_ignore_ascii_case("true"),
277                 "MimeType" => {
278                     mimes = v.split(';').map(str::trim).filter(|m| !m.is_empty()).map(String::from).collect();
279                 }
280                 "Categories" => {
281                     categories = v.split(';').map(str::trim).filter(|c| !c.is_empty()).map(String::from).collect();
282                 }
283                 "Exec" => {
284                     exec_cmd = v
285                         .split_whitespace()
286                         .next()
287                         .and_then(|t| t.rsplit('/').next())
288                         .map(String::from);
289                 }
290                 _ => {}
291             }
292         }
293     }
294     if hidden || app_type.as_deref() != Some("Application") {
295         return None;
296     }
297     Some(DesktopApp { name: name?, mimes, categories, exec_cmd, no_display })
298 }
299 
300 /// Scan every XDG applications dir with spec ID shadowing: the first dir that
301 /// defines an ID wins; subdirectories join the ID with `-`.
302 fn scan_desktop_entries() -> HashMap<String, DesktopApp> {
303     fn walk(dir: &std::path::Path, prefix: &str, out: &mut HashMap<String, DesktopApp>) {
304         let Ok(rd) = std::fs::read_dir(dir) else { return };
305         for e in rd.flatten() {
306             let path = e.path();
307             let Some(fname) = path.file_name().and_then(|n| n.to_str()) else { continue };
308             if path.is_dir() {
309                 walk(&path, &format!("{prefix}{fname}-"), out);
310             } else if fname.ends_with(".desktop") {
311                 let id = format!("{prefix}{fname}");
312                 if out.contains_key(&id) {
313                     continue; // shadowed by an earlier data dir
314                 }
315                 if let Some(app) = parse_desktop_file(&path) {
316                     out.insert(id, app);
317                 }
318             }
319         }
320     }
321     let mut out = HashMap::new();
322     for dir in desktop_dirs() {
323         walk(&dir, "", &mut out);
324     }
325     out
326 }
327 
328 pub async fn fetch_default_apps() -> DefaultAppsInfo {
329     let apps = tokio::task::spawn_blocking(scan_desktop_entries).await.unwrap_or_default();
330 
331     let mut cats = Vec::with_capacity(CATEGORIES.len());
332     for &(_, kind) in CATEGORIES {
333         let info = match kind {
334             CategoryKind::Mime(mimes) => fetch_mime_category(&apps, mimes).await,
335             CategoryKind::Terminal => fetch_terminal_category(&apps),
336         };
337         cats.push(info);
338     }
339     DefaultAppsInfo(cats)
340 }
341 
342 async fn query_default(mime: &str) -> Option<String> {
343     tokio::process::Command::new("xdg-mime")
344         .args(["query", "default", mime])
345         .output()
346         .await
347         .ok()
348         .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
349         .filter(|s| !s.is_empty())
350 }
351 
352 async fn fetch_mime_category(apps: &HashMap<String, DesktopApp>, mimes: &[&str]) -> CategoryInfo {
353     // The category's default is only real if EVERY type agrees — a partial
354     // set (one type lost to the write race above, or set by hand) shows as
355     // "not set" so re-picking repairs it, instead of reporting an app the
356     // other types don't actually resolve to.
357     let mut current = query_default(mimes[0]).await;
358     for mime in &mimes[1..] {
359         if current.is_none() {
360             break;
361         }
362         if query_default(mime).await != current {
363             current = None;
364         }
365     }
366 
367     let mut candidates: Vec<(String, String)> = apps
368         .iter()
369         .filter(|(id, app)| {
370             let claims = app.mimes.iter().any(|m| mimes.contains(&m.as_str()));
371             // NoDisplay apps stay hidden unless they ARE the default.
372             claims && (!app.no_display || current.as_deref() == Some(id.as_str()))
373         })
374         .map(|(id, app)| (id.clone(), app.name.clone()))
375         .collect();
376     candidates.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()));
377 
378     // A current default we didn't scan (odd install) still shows, by id.
379     if let Some(cur) = &current {
380         if !candidates.iter().any(|(id, _)| id == cur) {
381             candidates.push((cur.clone(), cur.trim_end_matches(".desktop").to_string()));
382         }
383     }
384     CategoryInfo { candidates, current }
385 }
386 
387 /// Candidates are `Categories=TerminalEmulator` entries keyed by COMMAND
388 /// (deduped — one terminal often ships several entries); the current value is
389 /// config.kdl's `default_terminal`, falling back to the launcher's compiled
390 /// foot fallback so the row shows what actually happens today.
391 fn fetch_terminal_category(apps: &HashMap<String, DesktopApp>) -> CategoryInfo {
392     let mut by_cmd: HashMap<&str, &str> = HashMap::new();
393     for app in apps.values() {
394         if app.no_display || !app.categories.iter().any(|c| c == "TerminalEmulator") {
395             continue;
396         }
397         if let Some(cmd) = app.exec_cmd.as_deref() {
398             // Prefer the shortest display name for a command (foot ships
399             // "Foot" and "Foot (server)" — the plain one reads best).
400             let name = by_cmd.entry(cmd).or_insert(&app.name);
401             if app.name.len() < name.len() {
402                 *name = &app.name;
403             }
404         }
405     }
406     let mut candidates: Vec<(String, String)> =
407         by_cmd.into_iter().map(|(cmd, name)| (cmd.to_string(), name.to_string())).collect();
408     candidates.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()));
409 
410     let current = cce_ui::config::get_string("/default_terminal")
411         .filter(|s| !s.is_empty())
412         .or_else(|| Some("foot".to_string()).filter(|_| candidates.iter().any(|(c, _)| c == "foot")));
413 
414     // A configured command with no matching entry still shows, as itself.
415     if let Some(cur) = &current {
416         if !candidates.iter().any(|(cmd, _)| cmd == cur) {
417             candidates.push((cur.clone(), cur.clone()));
418         }
419     }
420     CategoryInfo { candidates, current }
421 }
422 
423 impl crate::pages::AppPage for DefaultAppsState {
424     // Sections: [Default Apps]
425     // Mirrors the view's load gate (d13a901): the category dropdowns are added
426     // to the stack only in the `else` of `if !state.loaded`, so reporting them
427     // during the application scan is one dead root per category.
428     fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
429         if !self.loaded {
430             return vec![Vec::new()];
431         }
432         vec![self.categories.iter().map(|c| c.dropdown.id()).collect()]
433     }
434 
435     fn view(
436         &mut self,
437         cx: f32,
438         cy: f32,
439         cw: f32,
440         ch: f32,
441         root_focused: bool,
442         sec_focused: &[bool],
443         layout: &mut dyn LayoutStrategy,
444         ctx: &mut cce_ui::context::UiContext,
445     ) -> crate::app::PageContent {
446         view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
447     }
448 
449     fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>) {
450         for (i, entry) in self.categories.iter_mut().enumerate() {
451             if entry.dropdown.take_change() {
452                 actions.push(AppAction::DefaultApps(DefaultAppsMessage::Set(i, entry.dropdown.selected)));
453             }
454         }
455     }
456 }
457 
458 #[cfg(test)]
459 mod tests {
460     use super::*;
461 
462     #[test]
463     fn section_widgets_mirror_load_gate() {
464         use crate::pages::AppPage;
465         let mut st = DefaultAppsState::default();
466         // Still scanning: the dropdowns are not in the stack yet, so none of
467         // them may be reported as a dispatch root.
468         assert_eq!(st.section_widgets(), vec![Vec::new()]);
469         st.loaded = true;
470         assert_eq!(st.section_widgets()[0].len(), st.categories.len());
471     }
472 
473     #[test]
474     fn parse_desktop_file_basics() {
475         let dir = std::env::temp_dir().join("cce-da-test");
476         std::fs::create_dir_all(&dir).unwrap();
477         let p = dir.join("t.desktop");
478         std::fs::write(&p, "[Desktop Entry]\nType=Application\nName=Test App\nMimeType=text/html;image/png;\n\n[Desktop Action new]\nName=Other\n").unwrap();
479         let app = parse_desktop_file(&p).unwrap();
480         assert_eq!(app.name, "Test App");
481         assert_eq!(app.mimes, vec!["text/html", "image/png"]);
482         assert!(!app.no_display);
483 
484         std::fs::write(&p, "[Desktop Entry]\nType=Application\nName=H\nHidden=true\n").unwrap();
485         assert!(parse_desktop_file(&p).is_none());
486         std::fs::write(&p, "[Desktop Entry]\nType=Link\nName=L\n").unwrap();
487         assert!(parse_desktop_file(&p).is_none());
488     }
489 
490     #[test]
491     fn rebuild_options_maps_current() {
492         let mut st = DefaultAppsState::default();
493         let e = &mut st.categories[0];
494         e.info = CategoryInfo {
495             candidates: vec![
496                 ("a.desktop".into(), "Alpha".into()),
497                 ("b.desktop".into(), "Beta".into()),
498             ],
499             current: Some("b.desktop".into()),
500         };
501         rebuild_entry_options(e);
502         assert_eq!(e.dropdown.options, vec!["Alpha".to_string(), "Beta".to_string()]);
503         assert_eq!(e.dropdown.selected, 1);
504         assert_eq!(e.option_ids[1].as_deref(), Some("b.desktop"));
505 
506         // No current: placeholder row leads and is selected.
507         e.info.current = None;
508         rebuild_entry_options(e);
509         assert_eq!(e.dropdown.options[0], NOT_SET);
510         assert_eq!(e.dropdown.selected, 0);
511         assert!(e.option_ids[0].is_none());
512     }
513 }