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

src/pages/default_apps.rs (20.2K)

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