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