system settings
git clone https://git.lucas.co/cce-system-interface.git
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) = ¤t {
+ 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,