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) = ¤t {
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) = ¤t {
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 }