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

commit4df9e4baba25a9267dd6e9691fbb1a5d2910654e
parente7e1cbc5b6
authorLucas Galante <[email protected]>
date2026-08-10 19:13
feat: Default Apps page — configure XDG default applications

Eight curated categories (browser, mail, file manager, text editor,
images, audio, video, PDF), each a dropdown of the installed .desktop
entries claiming the category's MIME types (spec ID shadowing, Hidden
skipped, NoDisplay hidden unless current). Current defaults resolve via
`xdg-mime query default`; a pick applies `xdg-mime default` for every
type in the category. Background watcher refreshes while the page is
open.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/app.rs                |   6 +
 src/main.rs               |   9 ++
 src/pages/default_apps.rs | 357 ++++++++++++++++++++++++++++++++++++++++++++++
 src/pages/mod.rs          |   6 +-
 src/watchers.rs           |   5 +-
 5 files changed, 381 insertions(+), 2 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index beb4d07..15bdc6e 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,6 +1,7 @@
 use cce_ui::layout::RenderTarget;
 
 use crate::pages::audio;
+use crate::pages::default_apps;
 use crate::pages::network;
 use crate::pages::processes;
 use crate::pages::services;
@@ -15,6 +16,7 @@ use crate::pages::Page;
 pub struct AppState {
     pub current_page: Page,
     pub audio: audio::AudioState,
+    pub default_apps: default_apps::DefaultAppsState,
     pub network: network::NetworkState,
     pub processes: processes::ProcessesState,
     pub services: services::ServicesState,
@@ -31,6 +33,7 @@ impl Default for AppState {
         Self {
             current_page: Page::ALL[0],
             audio: audio::AudioState::default(),
+            default_apps: default_apps::DefaultAppsState::default(),
             network: network::NetworkState::default(),
             processes: processes::ProcessesState::default(),
             services: services::ServicesState::default(),
@@ -49,6 +52,7 @@ impl AppState {
         match page {
             Page::Accounts => &self.accounts,
             Page::Audio => &self.audio,
+            Page::DefaultApps => &self.default_apps,
             Page::Packages => &self.packages,
             Page::Processes => &self.processes,
             Page::Services => &self.services,
@@ -64,6 +68,7 @@ impl AppState {
         match page {
             Page::Accounts => &mut self.accounts,
             Page::Audio => &mut self.audio,
+            Page::DefaultApps => &mut self.default_apps,
             Page::Packages => &mut self.packages,
             Page::Processes => &mut self.processes,
             Page::Services => &mut self.services,
@@ -88,6 +93,7 @@ impl AppState {
 pub enum AppAction {
     Exit,
     Audio(audio::AudioMessage),
+    DefaultApps(default_apps::DefaultAppsMessage),
     Radios(network::NetworkMessage),
     Processes(processes::ProcessesMessage),
     Services(services::ServicesMessage),
diff --git a/src/main.rs b/src/main.rs
index fdbd79a..d6fad2c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -60,6 +60,7 @@ struct SystemInterface {
     rx_storage: std::sync::mpsc::Receiver<pages::storage::StorageState>,
     rx_notifications: std::sync::mpsc::Receiver<pages::notifications::NotificationsConfig>,
     rx_services: std::sync::mpsc::Receiver<Vec<pages::services::ServiceInfo>>,
+    rx_default_apps: std::sync::mpsc::Receiver<pages::default_apps::DefaultAppsInfo>,
     rx_fonts: std::sync::mpsc::Receiver<pages::fonts::FontsState>,
     rx_accounts: std::sync::mpsc::Receiver<Vec<pages::accounts::AccountInfo>>,
     tx_backup: std::sync::mpsc::Sender<pages::storage::StorageMessage>,
@@ -154,6 +155,7 @@ impl cce_ui::engine::Application for SystemInterface {
             rx_audio: watchers.rx_audio,
             rx_network: watchers.rx_network,
             rx_processes: watchers.rx_processes,
+            rx_default_apps: watchers.rx_default_apps,
             rx_system: watchers.rx_system,
             rx_storage: watchers.rx_storage,
             rx_notifications: watchers.rx_notifications,
@@ -523,6 +525,12 @@ impl SystemInterface {
                 self.needs_rebuild = true;
             }
         }
+        while let Ok(s) = self.rx_default_apps.try_recv() {
+            pages::default_apps::update(&mut self.app.default_apps, pages::default_apps::DefaultAppsMessage::Refreshed(s));
+            if self.app.current_page == Page::DefaultApps {
+                self.needs_rebuild = true;
+            }
+        }
         while let Ok(s) = self.rx_services.try_recv() {
             services::update(&mut self.app.services, services::ServicesMessage::Refreshed(s));
             if self.app.current_page == Page::Services {
@@ -565,6 +573,7 @@ impl SystemInterface {
             AppAction::SystemInfo(m) => system_info::update(&mut self.app.system_info, m.clone(), &mut self.ui_context),
             AppAction::Processes(m) => processes::update(&mut self.app.processes, m.clone()),
             AppAction::Services(m) => services::update(&mut self.app.services, m.clone()),
+            AppAction::DefaultApps(m) => pages::default_apps::update(&mut self.app.default_apps, m.clone()),
             AppAction::Notifications(m) => notifications::update(&mut self.app.notifications, m.clone()),
             AppAction::Storage(m) => match m {
                 pages::storage::StorageMessage::StartBackup => {
diff --git a/src/pages/default_apps.rs b/src/pages/default_apps.rs
new file mode 100644
index 0000000..183166a
--- /dev/null
+++ b/src/pages/default_apps.rs
@@ -0,0 +1,357 @@
+//! XDG default applications: curated categories over `~/.config/mimeapps.list`.
+//! Candidates come from the installed `.desktop` entries that claim the
+//! category's MIME types; picks are applied through `xdg-mime default`, one
+//! call per type, so a browser pick covers http/https/text-html at once.
+
+use crate::app::{AppAction, PageContent};
+use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy};
+use cce_ui::widget::{Dropdown, WidgetHost};
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+/// (label, MIME types) — the first type is the one queried for the current
+/// default; a pick sets every type in the list.
+const CATEGORIES: &[(&str, &[&str])] = &[
+    ("Web Browser", &["x-scheme-handler/http", "x-scheme-handler/https", "text/html"]),
+    ("Mail", &["x-scheme-handler/mailto"]),
+    ("File Manager", &["inode/directory"]),
+    ("Text Editor", &["text/plain"]),
+    ("Images", &["image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"]),
+    ("Audio", &["audio/mpeg", "audio/flac", "audio/ogg", "audio/x-wav"]),
+    ("Video", &["video/mp4", "video/x-matroska", "video/webm"]),
+    ("PDF", &["application/pdf"]),
+];
+
+const NOT_SET: &str = "— not set —";
+
+/// One category's fetched facts: (desktop id, display name) candidates and the
+/// current default's desktop id. Plain data — the widget state lives in
+/// [`DefaultAppsState`].
+#[derive(Debug, Clone, PartialEq)]
+pub struct CategoryInfo {
+    pub candidates: Vec<(String, String)>,
+    pub current: Option<String>,
+}
+
+#[derive(Debug, Clone)]
+pub struct DefaultAppsInfo(pub Vec<CategoryInfo>);
+
+#[derive(Debug, Clone)]
+pub struct CategoryEntry {
+    pub label: &'static str,
+    pub mimes: &'static [&'static str],
+    pub info: CategoryInfo,
+    /// Desktop id per dropdown option (None = the "not set" placeholder row).
+    pub option_ids: Vec<Option<String>>,
+    pub dropdown: cce_ui::widget::Adapted<Dropdown>,
+}
+
+#[derive(Debug, Clone)]
+pub struct DefaultAppsState {
+    pub loaded: bool,
+    pub categories: Vec<CategoryEntry>,
+}
+
+impl Default for DefaultAppsState {
+    fn default() -> Self {
+        Self {
+            loaded: false,
+            categories: CATEGORIES
+                .iter()
+                .map(|&(label, mimes)| CategoryEntry {
+                    label,
+                    mimes,
+                    info: CategoryInfo { candidates: Vec::new(), current: None },
+                    option_ids: vec![None],
+                    dropdown: Dropdown::new(vec![NOT_SET.to_string()], 0).with_label(label),
+                })
+                .collect(),
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
+pub enum DefaultAppsMessage {
+    Refreshed(DefaultAppsInfo),
+    /// The user picked dropdown option `1` in category `0`.
+    Set(usize, usize),
+}
+
+const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
+
+fn rebuild_entry_options(entry: &mut CategoryEntry) {
+    let mut options = Vec::new();
+    let mut ids = Vec::new();
+    let current_idx = entry
+        .info
+        .current
+        .as_ref()
+        .and_then(|cur| entry.info.candidates.iter().position(|(id, _)| id == cur));
+    if entry.info.current.is_none() || current_idx.is_none() {
+        options.push(NOT_SET.to_string());
+        ids.push(None);
+    }
+    for (id, name) in &entry.info.candidates {
+        options.push(name.clone());
+        ids.push(Some(id.clone()));
+    }
+    let selected = entry
+        .info
+        .current
+        .as_ref()
+        .and_then(|cur| ids.iter().position(|i| i.as_deref() == Some(cur)))
+        .unwrap_or(0);
+    entry.dropdown.options = options;
+    entry.dropdown.selected = selected;
+    entry.option_ids = ids;
+}
+
+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 {
+    let mut final_pc = PageContent::new();
+    let sec_w = 320.0f32;
+    let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
+
+    builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
+        let sec_w = sec.cw;
+        if !state.loaded {
+            sec.text("Scanning installed applications...", 12.0, 0.0, 12.0, TEXT_DIM);
+        } else {
+            let mut stack = sec.vstack(8.0);
+            for entry in state.categories.iter_mut() {
+                entry.dropdown.set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
+                stack.add_widget(&mut entry.dropdown, sec_w - 28.0, 44.0, ctx);
+            }
+        }
+    });
+
+    final_pc
+}
+
+pub fn update(state: &mut DefaultAppsState, msg: DefaultAppsMessage) {
+    match msg {
+        DefaultAppsMessage::Refreshed(info) => {
+            state.loaded = true;
+            for (entry, cat) in state.categories.iter_mut().zip(info.0.into_iter()) {
+                // Leave an open dropdown alone — the next refresh normalizes it.
+                if entry.info == cat || entry.dropdown.open {
+                    continue;
+                }
+                entry.info = cat;
+                rebuild_entry_options(entry);
+            }
+        }
+        DefaultAppsMessage::Set(cat_idx, opt_idx) => {
+            let Some(entry) = state.categories.get_mut(cat_idx) else { return };
+            let Some(Some(id)) = entry.option_ids.get(opt_idx).cloned() else { return };
+            for mime in entry.mimes {
+                let _ = tokio::process::Command::new("xdg-mime")
+                    .args(["default", &id, mime])
+                    .spawn();
+            }
+            entry.info.current = Some(id);
+            rebuild_entry_options(entry);
+        }
+    }
+}
+
+// ── Background fetching ──
+
+struct DesktopApp {
+    name: String,
+    mimes: Vec<String>,
+    no_display: bool,
+}
+
+fn desktop_dirs() -> Vec<PathBuf> {
+    let mut dirs = Vec::new();
+    let data_home = std::env::var("XDG_DATA_HOME")
+        .ok()
+        .filter(|s| !s.is_empty())
+        .map(PathBuf::from)
+        .or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".local/share")));
+    if let Some(h) = data_home {
+        dirs.push(h.join("applications"));
+    }
+    let data_dirs = std::env::var("XDG_DATA_DIRS").ok().filter(|s| !s.is_empty())
+        .unwrap_or_else(|| "/usr/local/share:/usr/share".to_string());
+    for d in data_dirs.split(':').filter(|d| !d.is_empty()) {
+        dirs.push(PathBuf::from(d).join("applications"));
+    }
+    dirs
+}
+
+/// Parse the `[Desktop Entry]` group of one `.desktop` file. Returns None for
+/// non-applications and `Hidden=true` entries (spec: treated as nonexistent).
+fn parse_desktop_file(path: &std::path::Path) -> Option<DesktopApp> {
+    let content = std::fs::read_to_string(path).ok()?;
+    let mut in_entry = false;
+    let mut name = None;
+    let mut mimes = Vec::new();
+    let mut app_type = None;
+    let mut hidden = false;
+    let mut no_display = false;
+    for line in content.lines() {
+        let line = line.trim();
+        if line.starts_with('[') {
+            in_entry = line == "[Desktop Entry]";
+            continue;
+        }
+        if !in_entry {
+            continue;
+        }
+        if let Some((k, v)) = line.split_once('=') {
+            match k.trim() {
+                "Name" if name.is_none() => name = Some(v.trim().to_string()),
+                "Type" => app_type = Some(v.trim().to_string()),
+                "Hidden" => hidden = v.trim().eq_ignore_ascii_case("true"),
+                "NoDisplay" => no_display = v.trim().eq_ignore_ascii_case("true"),
+                "MimeType" => {
+                    mimes = v.split(';').map(str::trim).filter(|m| !m.is_empty()).map(String::from).collect();
+                }
+                _ => {}
+            }
+        }
+    }
+    if hidden || app_type.as_deref() != Some("Application") {
+        return None;
+    }
+    Some(DesktopApp { name: name?, mimes, no_display })
+}
+
+/// Scan every XDG applications dir with spec ID shadowing: the first dir that
+/// defines an ID wins; subdirectories join the ID with `-`.
+fn scan_desktop_entries() -> HashMap<String, DesktopApp> {
+    fn walk(dir: &std::path::Path, prefix: &str, out: &mut HashMap<String, DesktopApp>) {
+        let Ok(rd) = std::fs::read_dir(dir) else { return };
+        for e in rd.flatten() {
+            let path = e.path();
+            let Some(fname) = path.file_name().and_then(|n| n.to_str()) else { continue };
+            if path.is_dir() {
+                walk(&path, &format!("{prefix}{fname}-"), out);
+            } else if fname.ends_with(".desktop") {
+                let id = format!("{prefix}{fname}");
+                if out.contains_key(&id) {
+                    continue; // shadowed by an earlier data dir
+                }
+                if let Some(app) = parse_desktop_file(&path) {
+                    out.insert(id, app);
+                }
+            }
+        }
+    }
+    let mut out = HashMap::new();
+    for dir in desktop_dirs() {
+        walk(&dir, "", &mut out);
+    }
+    out
+}
+
+pub async fn fetch_default_apps() -> DefaultAppsInfo {
+    let apps = tokio::task::spawn_blocking(scan_desktop_entries).await.unwrap_or_default();
+
+    let mut cats = Vec::with_capacity(CATEGORIES.len());
+    for &(_, mimes) in CATEGORIES {
+        let current = tokio::process::Command::new("xdg-mime")
+            .args(["query", "default", mimes[0]])
+            .output()
+            .await
+            .ok()
+            .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
+            .filter(|s| !s.is_empty());
+
+        let mut candidates: Vec<(String, String)> = apps
+            .iter()
+            .filter(|(id, app)| {
+                let claims = app.mimes.iter().any(|m| mimes.contains(&m.as_str()));
+                // NoDisplay apps stay hidden unless they ARE the default.
+                claims && (!app.no_display || current.as_deref() == Some(id.as_str()))
+            })
+            .map(|(id, app)| (id.clone(), app.name.clone()))
+            .collect();
+        candidates.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()));
+
+        // A current default we didn't scan (odd install) still shows, by id.
+        if let Some(cur) = &current {
+            if !candidates.iter().any(|(id, _)| id == cur) {
+                candidates.push((cur.clone(), cur.trim_end_matches(".desktop").to_string()));
+            }
+        }
+        cats.push(CategoryInfo { candidates, current });
+    }
+    DefaultAppsInfo(cats)
+}
+
+impl crate::pages::AppPage for DefaultAppsState {
+    // Sections: [Default Apps]
+    fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
+        vec![self.categories.iter().map(|c| c.dropdown.id()).collect()]
+    }
+
+    fn view(
+        &mut self,
+        cx: f32,
+        cy: f32,
+        cw: f32,
+        ch: f32,
+        root_focused: bool,
+        sec_focused: &[bool],
+        layout: &mut dyn LayoutStrategy,
+        ctx: &mut cce_ui::context::UiContext,
+    ) -> crate::app::PageContent {
+        view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
+    }
+
+    fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>) {
+        for (i, entry) in self.categories.iter_mut().enumerate() {
+            if entry.dropdown.take_change() {
+                actions.push(AppAction::DefaultApps(DefaultAppsMessage::Set(i, entry.dropdown.selected)));
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parse_desktop_file_basics() {
+        let dir = std::env::temp_dir().join("cce-da-test");
+        std::fs::create_dir_all(&dir).unwrap();
+        let p = dir.join("t.desktop");
+        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();
+        let app = parse_desktop_file(&p).unwrap();
+        assert_eq!(app.name, "Test App");
+        assert_eq!(app.mimes, vec!["text/html", "image/png"]);
+        assert!(!app.no_display);
+
+        std::fs::write(&p, "[Desktop Entry]\nType=Application\nName=H\nHidden=true\n").unwrap();
+        assert!(parse_desktop_file(&p).is_none());
+        std::fs::write(&p, "[Desktop Entry]\nType=Link\nName=L\n").unwrap();
+        assert!(parse_desktop_file(&p).is_none());
+    }
+
+    #[test]
+    fn rebuild_options_maps_current() {
+        let mut st = DefaultAppsState::default();
+        let e = &mut st.categories[0];
+        e.info = CategoryInfo {
+            candidates: vec![
+                ("a.desktop".into(), "Alpha".into()),
+                ("b.desktop".into(), "Beta".into()),
+            ],
+            current: Some("b.desktop".into()),
+        };
+        rebuild_entry_options(e);
+        assert_eq!(e.dropdown.options, vec!["Alpha".to_string(), "Beta".to_string()]);
+        assert_eq!(e.dropdown.selected, 1);
+        assert_eq!(e.option_ids[1].as_deref(), Some("b.desktop"));
+
+        // No current: placeholder row leads and is selected.
+        e.info.current = None;
+        rebuild_entry_options(e);
+        assert_eq!(e.dropdown.options[0], NOT_SET);
+        assert_eq!(e.dropdown.selected, 0);
+        assert!(e.option_ids[0].is_none());
+    }
+}
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index 07e1ea7..e3edcc8 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -1,4 +1,5 @@
 pub mod audio;
+pub mod default_apps;
 pub mod network;
 pub mod storage;
 pub mod system_info;
@@ -13,6 +14,7 @@ pub mod notifications;
 pub enum Page {
     Accounts,
     Audio,
+    DefaultApps,
     Notifications,
     Radios,
     Storage,
@@ -24,9 +26,10 @@ pub enum Page {
 }
 
 impl Page {
-    pub const ALL: [Page; 10] = [
+    pub const ALL: [Page; 11] = [
         Page::Accounts,
         Page::Audio,
+        Page::DefaultApps,
         Page::Fonts,
         Page::Notifications,
         Page::Packages,
@@ -41,6 +44,7 @@ impl Page {
         match self {
             Page::Accounts => "Accounts",
             Page::Audio => "Audio",
+            Page::DefaultApps => "Default Apps",
             Page::Notifications => "Notifications",
             Page::Radios => "Radios",
             Page::Storage => "Storage",
diff --git a/src/watchers.rs b/src/watchers.rs
index eb8eaba..32b0638 100644
--- a/src/watchers.rs
+++ b/src/watchers.rs
@@ -1,7 +1,7 @@
 use std::sync::Arc;
 use std::sync::atomic::{AtomicU8, Ordering};
 use std::sync::mpsc::{channel, Receiver, Sender};
-use crate::pages::{Page, audio, network, fonts, processes, services, system_info, storage, packages, accounts, notifications};
+use crate::pages::{Page, audio, default_apps, network, fonts, processes, services, system_info, storage, packages, accounts, notifications};
 
 pub struct Watchers {
     pub rx_audio: Receiver<audio::AudioState>,
@@ -11,6 +11,7 @@ pub struct Watchers {
     pub rx_storage: Receiver<storage::StorageState>,
     pub rx_notifications: Receiver<notifications::NotificationsConfig>,
     pub rx_services: Receiver<Vec<services::ServiceInfo>>,
+    pub rx_default_apps: Receiver<default_apps::DefaultAppsInfo>,
     pub rx_fonts: Receiver<fonts::FontsState>,
     pub rx_accounts: Receiver<Vec<accounts::AccountInfo>>,
     pub rx_packages: Receiver<packages::PackagesState>,
@@ -92,6 +93,7 @@ pub fn spawn_all(
 
     let rx_fonts = spawn_bg_active(current_page_shared.clone(), Page::Fonts.index() as u8, 30, || fonts::fetch_typeface_state());
     let rx_services = spawn_bg_active(current_page_shared.clone(), Page::Services.index() as u8, 3, || services::fetch_services());
+    let rx_default_apps = spawn_bg_active(current_page_shared.clone(), Page::DefaultApps.index() as u8, 10, || default_apps::fetch_default_apps());
     let rx_accounts = spawn_bg_active(current_page_shared.clone(), Page::Accounts.index() as u8, 3, || accounts::fetch_accounts());
 
     let (tx_backup, rx_backup) = channel();
@@ -107,6 +109,7 @@ pub fn spawn_all(
             rx_storage,
             rx_notifications,
             rx_services,
+            rx_default_apps,
             rx_fonts,
             rx_accounts,
             rx_packages,