Files
umutray/src/gui.rs
T
funman300 2f4f1c64d2 refactor: idiomatic Rust cleanup and quality improvements
- Replace .map().unwrap_or(false) with .is_some_and()/.is_ok_and()
- Use path.display() instead of {:?} for user-facing messages
- Replace Option<Option<Vec<String>>> with GamescopeUpdate enum
- Replace manual parent-walking loops with .ancestors() iterators
- Simplify kill()/kill_all() signatures to return () instead of Result
- Use tokio::task::spawn_blocking instead of hand-rolled thread+oneshot
- Read /proc/self/status for UID instead of spawning id subprocess
- Build Exec= line directly in render_desktop instead of string-replace
- Bump PKGBUILD pkgrel to 6
2026-04-19 11:29:42 -07:00

1731 lines
63 KiB
Rust

use crate::{
config::Config, detect, diagnose, launcher, proton, service,
theme::{
btn_accent, btn_danger, btn_ghost, card_style, icon, section_heading, sub_card_style,
surface_bg, ACCENT, BORDER_CLR, DIM, GREEN, MUTED, NO_SHADOW, RED,
},
util::{async_blocking, pick_file, pick_folder},
};
use anyhow::Result;
use iced::widget::{
button, column, container, mouse_area, pick_list, row, scrollable, text, text_input, Space,
Column,
};
use iced::{
Alignment, Background, Border, Color, Element, Length, Padding, Subscription, Task, Theme,
};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// What the user chose when closing the GUI window.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CloseAction {
Quit,
MinimizeToTray,
}
#[derive(Debug, Clone)]
pub enum Message {
FontLoaded,
PollProcesses,
PollDone(HashMap<String, bool>),
ReloadConfig,
AddLauncher,
Launch(String),
LaunchDone(String, Result<(), String>),
Kill(String),
Play(String, String),
PlayDone(String, String, Result<(), String>),
Setup(String),
ToggleGameMode(String, String),
ToggleMangoHud(String, String),
ToggleGamescope(String, String),
UpdateProton,
ProtonDone(Result<(), String>),
KillDone(String, Result<(), String>),
// Context menu
ShowContextMenu(String),
HideContextMenu,
OpenPrefix(String),
RerunSetup(String),
RemoveLauncher(String),
// Detect
DetectPressed,
DetectDone(Vec<detect::DetectHit>),
// Diagnose
DiagnosePressed(String),
DiagnoseDone(String, Vec<diagnose::CheckResult>),
HideDiagnose,
// Games
AddGamePressed(String),
AddGameNameChanged(String, String),
AddGameExeChanged(String, String),
AddGameConfirm(String),
RemoveGame(String, String),
ScanGames(String),
ScanGamesDone(String, Vec<(String, String)>),
AddScannedGame(String, String, String),
BrowseGameExe(String),
BrowseGameExeDone(String, Option<String>),
// Settings
ShowSettings,
HideSettings,
SettingsProtonVersionChanged(String),
SettingsCompatDirChanged(String),
BrowseCompatDir,
BrowseCompatDirDone(Option<String>),
SaveSettings,
ServiceInstall,
ServiceUninstall,
ServiceActionDone(Result<(), String>),
LaunchProtontricks,
// Close dialog
CloseRequested(iced::window::Id),
ConfirmQuit,
ConfirmMinimize,
CancelClose,
}
struct Dashboard {
config: Config,
running: HashMap<String, bool>,
proton_busy: bool,
proton_status: String,
last_error: Option<String>,
context_menu: Option<String>,
/// Launchers currently being launched (show spinner instead of button).
launch_busy: std::collections::HashSet<String>,
// Detect
detect_busy: bool,
detect_result: String,
// Diagnose
diagnose_open: Option<String>,
diagnose_result: Option<(String, Vec<diagnose::CheckResult>)>,
// Games
adding_game: HashMap<String, (String, String)>,
scan_results: HashMap<String, Vec<(String, String)>>,
scan_busy: std::collections::HashSet<String>,
// Settings
settings_open: bool,
settings_proton_version: String,
settings_compat_dir: String,
proton_versions: Vec<String>,
service_busy: bool,
service_status: String,
// Close dialog
close_dialog_open: bool,
close_action: Arc<Mutex<Option<CloseAction>>>,
}
impl Dashboard {
fn new(config: Config, close_action: Arc<Mutex<Option<CloseAction>>>) -> Self {
let mut running = HashMap::new();
for l in &config.launchers {
running.insert(l.name.clone(), launcher::is_running(l));
}
let settings_proton_version = config.proton_version.clone();
let settings_compat_dir = config.proton_compat_dir.to_string_lossy().into_owned();
let proton_versions = proton::list_installed(&config);
Self {
config,
running,
proton_busy: false,
proton_status: String::new(),
last_error: None,
context_menu: None,
launch_busy: std::collections::HashSet::new(),
detect_busy: false,
detect_result: String::new(),
diagnose_open: None,
diagnose_result: None,
adding_game: HashMap::new(),
scan_results: HashMap::new(),
scan_busy: std::collections::HashSet::new(),
settings_open: false,
settings_proton_version,
settings_compat_dir,
proton_versions,
service_busy: false,
service_status: String::new(),
close_dialog_open: false,
close_action,
}
}
}
fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
match msg {
Message::FontLoaded => Task::none(),
Message::PollProcesses => {
let launchers: Vec<_> = state.config.launchers.iter()
.map(|l| (l.name.clone(), l.process_pattern.clone()))
.collect();
Task::perform(
async_blocking(move || {
let mut map = HashMap::new();
for (name, pattern) in launchers {
let running = std::process::Command::new("pgrep")
.args(["-f", &pattern])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success());
map.insert(name, running);
}
map
}),
Message::PollDone,
)
}
Message::PollDone(snapshot) => {
state.running = snapshot;
// Clear launch_busy for launchers that are now running
state.launch_busy.retain(|n| !state.running.get(n).copied().unwrap_or_default());
Task::none()
}
Message::ReloadConfig => {
if let Ok(fresh) = Config::load() {
state
.running
.retain(|k, _| fresh.launchers.iter().any(|l| &l.name == k));
state.proton_versions = proton::list_installed(&fresh);
state.config = fresh;
}
Task::none()
}
Message::AddLauncher => {
state.context_menu = None;
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
let _ = std::process::Command::new(exe).arg("setup").spawn();
Task::none()
}
Message::Launch(name) => {
state.last_error = None;
state.launch_busy.insert(name.clone());
let Some(l) = state.config.find(&name) else {
return Task::none();
};
let config = state.config.clone();
let l = l.clone();
let name2 = name.clone();
Task::perform(
async_blocking(move || launcher::launch(&config, &l).map_err(|e| e.to_string())),
move |res| Message::LaunchDone(name2.clone(), res),
)
}
Message::LaunchDone(name, res) => {
state.launch_busy.remove(&name);
match res {
Ok(()) => {
state.running.insert(name, true);
}
Err(e) => {
state.last_error = Some(format!("Launch failed: {e}"));
}
}
Task::none()
}
Message::Kill(name) => {
state.last_error = None;
let Some(l) = state.config.find(&name) else {
return Task::none();
};
let l = l.clone();
let name2 = name.clone();
state.running.insert(name, false);
Task::perform(
async_blocking(move || { launcher::kill(&l); Ok::<(), String>(()) }),
move |res| Message::KillDone(name2.clone(), res),
)
}
Message::KillDone(name, res) => {
if let Err(e) = res {
state.running.insert(name, true);
state.last_error = Some(format!("Kill failed: {e}"));
}
Task::none()
}
Message::Play(lname, gname) => {
state.last_error = None;
if let Some(l) = state.config.find(&lname) {
if let Some(g) = l.find_game(&gname) {
let config = state.config.clone();
let l = l.clone();
let g = g.clone();
let lname2 = lname.clone();
let gname2 = gname.clone();
return Task::perform(
async_blocking(move || {
launcher::play_game(&config, &l, &g).map_err(|e| e.to_string())
}),
move |res| Message::PlayDone(lname2.clone(), gname2.clone(), res),
);
}
}
Task::none()
}
Message::PlayDone(_lname, _gname, res) => {
if let Err(e) = res {
state.last_error = Some(format!("Play failed: {e}"));
}
Task::none()
}
Message::Setup(name) => {
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
let _ = std::process::Command::new(exe)
.arg("setup")
.arg(&name)
.spawn();
Task::none()
}
Message::ToggleGameMode(lname, gname) => {
toggle_flag(&mut state.config, &lname, &gname, |g| {
g.gamemode = !g.gamemode
});
Task::none()
}
Message::ToggleMangoHud(lname, gname) => {
toggle_flag(&mut state.config, &lname, &gname, |g| {
g.mangohud = !g.mangohud
});
Task::none()
}
Message::ToggleGamescope(lname, gname) => {
toggle_flag(&mut state.config, &lname, &gname, |g| {
g.gamescope = if g.gamescope.is_some() { None } else { Some(vec![]) };
});
Task::none()
}
Message::UpdateProton => {
state.proton_busy = true;
state.proton_status = "Downloading latest GE-Proton…".into();
state.last_error = None;
let config = state.config.clone();
Task::perform(
async_blocking(move || {
crate::proton::install_latest(&config).map_err(|e| e.to_string())
}),
Message::ProtonDone,
)
}
Message::ProtonDone(res) => {
state.proton_busy = false;
match res {
Ok(()) => state.proton_status = "GE-Proton updated successfully.".into(),
Err(e) => {
state.proton_status = String::new();
state.last_error = Some(format!("Proton update failed: {e}"));
}
}
Task::none()
}
// ── Context menu ───────────────────────────────────────────────────
Message::ShowContextMenu(name) => {
state.context_menu = if state.context_menu.as_deref() == Some(&name) {
None
} else {
Some(name)
};
Task::none()
}
Message::HideContextMenu => {
state.context_menu = None;
Task::none()
}
Message::OpenPrefix(name) => {
state.context_menu = None;
if let Some(l) = state.config.find(&name) {
let path = l.prefix_dir.clone();
std::thread::spawn(move || {
let _ = std::process::Command::new("xdg-open").arg(path).spawn();
});
}
Task::none()
}
Message::RerunSetup(name) => {
state.context_menu = None;
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
let _ = std::process::Command::new(exe)
.arg("setup")
.arg(&name)
.spawn();
Task::none()
}
Message::RemoveLauncher(name) => {
state.context_menu = None;
state.running.remove(&name);
state.adding_game.remove(&name);
if let Err(e) = state.config.remove_launcher(&name) {
state.last_error = Some(format!("Remove failed: {e}"));
}
Task::none()
}
// ── Detect ─────────────────────────────────────────────────────────
Message::DetectPressed => {
state.detect_busy = true;
state.detect_result = "Scanning…".into();
let config = state.config.clone();
Task::perform(
async_blocking(move || detect::scan_for_gui(&config)),
Message::DetectDone,
)
}
Message::DetectDone(hits) => {
state.detect_busy = false;
if hits.is_empty() {
state.detect_result = "No known launchers found on disk.".into();
} else {
let parts: Vec<String> = hits
.iter()
.map(|h| {
if h.configured {
format!("{}", h.display)
} else {
format!("{} @ {}", h.display, h.prefix.display())
}
})
.collect();
state.detect_result = format!("Found: {}", parts.join(", "));
}
Task::none()
}
// ── Diagnose ───────────────────────────────────────────────────────
Message::DiagnosePressed(name) => {
state.context_menu = None;
state.diagnose_open = Some(name.clone());
state.diagnose_result = None;
let config = state.config.clone();
let lname = name.clone();
Task::perform(
async_blocking(move || {
diagnose::run_checks(&config, Some(&name))
.unwrap_or_else(|e| vec![diagnose::CheckResult {
label: "error".into(),
pass: false,
detail: e.to_string(),
}])
}),
move |checks| Message::DiagnoseDone(lname.clone(), checks),
)
}
Message::DiagnoseDone(name, checks) => {
if state.diagnose_open.as_deref() == Some(&name) {
state.diagnose_result = Some((name, checks));
}
Task::none()
}
Message::HideDiagnose => {
state.diagnose_open = None;
state.diagnose_result = None;
Task::none()
}
// ── Games ──────────────────────────────────────────────────────────
Message::AddGamePressed(lname) => {
use std::collections::hash_map::Entry;
match state.adding_game.entry(lname) {
Entry::Occupied(e) => { e.remove(); }
Entry::Vacant(e) => { e.insert((String::new(), String::new())); }
}
Task::none()
}
Message::AddGameNameChanged(lname, val) => {
if let Some(entry) = state.adding_game.get_mut(&lname) {
entry.0 = val;
}
Task::none()
}
Message::AddGameExeChanged(lname, val) => {
if let Some(entry) = state.adding_game.get_mut(&lname) {
entry.1 = val;
}
Task::none()
}
Message::AddGameConfirm(lname) => {
let Some((name, exe)) = state.adding_game.get(&lname).cloned() else {
return Task::none();
};
let name = name.trim().to_string();
let exe = exe.trim().to_string();
if name.is_empty() || exe.is_empty() {
state.last_error = Some("Game name and exe path are required.".into());
return Task::none();
}
state.last_error = None;
match state.config.add_game(&lname, name, None, PathBuf::from(exe), false, false, None) {
Ok(()) => {
state.adding_game.remove(&lname);
}
Err(e) => {
state.last_error = Some(format!("Add game failed: {e}"));
}
}
Task::none()
}
Message::RemoveGame(lname, gname) => {
state.last_error = None;
if let Err(e) = state.config.remove_game(&lname, &gname) {
state.last_error = Some(format!("Remove game failed: {e}"));
}
Task::none()
}
Message::ScanGames(lname) => {
if let Some(l) = state.config.find(&lname) {
let l = l.clone();
let lname2 = lname.clone();
state.scan_busy.insert(lname);
return Task::perform(
async_blocking(move || detect::scan_games_in_prefix(&l)),
move |hits| Message::ScanGamesDone(lname2.clone(), hits),
);
}
Task::none()
}
Message::ScanGamesDone(lname, hits) => {
state.scan_busy.remove(&lname);
state.scan_results.insert(lname, hits);
Task::none()
}
Message::AddScannedGame(lname, display, exe) => {
state.last_error = None;
match state.config.add_game(
&lname,
display.to_lowercase().replace(' ', "_"),
Some(display),
PathBuf::from(&exe),
false,
false,
None,
) {
Ok(()) => {
// Remove this exe from scan results so it disappears
if let Some(results) = state.scan_results.get_mut(&lname) {
results.retain(|(_, e)| e != &exe);
}
}
Err(e) => state.last_error = Some(format!("Add game failed: {e}")),
}
Task::none()
}
Message::BrowseGameExe(lname) => {
let start = state
.config
.find(&lname)
.map(|l| l.prefix_dir.join("drive_c").to_string_lossy().into_owned())
.unwrap_or_default();
let lname2 = lname.clone();
Task::perform(
async_blocking(move || pick_file("Select game executable", &start)),
move |res| Message::BrowseGameExeDone(lname2.clone(), res),
)
}
Message::BrowseGameExeDone(lname, path_opt) => {
if let (Some(path_str), Some(l)) =
(path_opt, state.config.find(&lname))
{
let drive_c = l.prefix_dir.join("drive_c");
let abs = PathBuf::from(&path_str);
let rel = abs
.strip_prefix(&drive_c)
.map(|r| r.to_string_lossy().to_string())
.unwrap_or(path_str);
let display = abs
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("game")
.to_string();
let entry = state.adding_game.entry(lname).or_default();
if entry.0.is_empty() {
entry.0 = display;
}
entry.1 = rel;
}
Task::none()
}
// ── Settings ───────────────────────────────────────────────────────
Message::ShowSettings => {
state.settings_open = true;
state.settings_proton_version = state.config.proton_version.clone();
state.settings_compat_dir =
state.config.proton_compat_dir.to_string_lossy().into_owned();
state.proton_versions = proton::list_installed(&state.config);
state.service_status = String::new();
Task::none()
}
Message::HideSettings => {
state.settings_open = false;
Task::none()
}
Message::SettingsProtonVersionChanged(v) => {
state.settings_proton_version = v;
Task::none()
}
Message::SettingsCompatDirChanged(v) => {
state.settings_compat_dir = v;
Task::none()
}
Message::BrowseCompatDir => Task::perform(
async_blocking(|| pick_folder("Choose GE-Proton compat directory")),
Message::BrowseCompatDirDone,
),
Message::BrowseCompatDirDone(path) => {
if let Some(p) = path {
state.settings_compat_dir = p;
state.proton_versions =
proton::list_installed_from(PathBuf::from(&state.settings_compat_dir));
}
Task::none()
}
Message::SaveSettings => {
state.last_error = None;
let version = state.settings_proton_version.trim().to_string();
let compat = PathBuf::from(state.settings_compat_dir.trim());
// Validate compat dir
if !compat.is_absolute() {
state.last_error = Some("Compat directory must be an absolute path.".into());
return Task::none();
}
// Validate proton version: accept "GE-Proton" (latest) or a
// specific version like "GE-Proton10-1".
let version_key = if version == "GE-Proton (latest)" {
"GE-Proton".to_string()
} else {
version.clone()
};
if version_key.is_empty() || !version_key.starts_with("GE-Proton") {
state.last_error =
Some(format!("Unknown Proton version \"{version_key}\". Expected \"GE-Proton\" or a specific version like \"GE-Proton10-1\"."));
return Task::none();
}
match state.config.set_globals(Some(version_key), Some(compat)) {
Ok(()) => {
state.proton_versions = proton::list_installed(&state.config);
state.service_status = "Settings saved.".into();
}
Err(e) => {
state.last_error = Some(format!("Save failed: {e}"));
}
}
Task::none()
}
Message::LaunchProtontricks => {
std::thread::spawn(|| {
let _ = std::process::Command::new("protontricks")
.arg("--gui")
.spawn();
});
Task::none()
}
Message::ServiceInstall => {
state.service_busy = true;
state.service_status = "Installing autostart…".into();
Task::perform(
async_blocking(|| service::install().map_err(|e| e.to_string())),
Message::ServiceActionDone,
)
}
Message::ServiceUninstall => {
state.service_busy = true;
state.service_status = "Removing autostart…".into();
Task::perform(
async_blocking(|| service::uninstall().map_err(|e| e.to_string())),
Message::ServiceActionDone,
)
}
Message::ServiceActionDone(res) => {
state.service_busy = false;
match res {
Ok(()) => {
state.service_status = if service_is_installed() {
"Autostart enabled — starts on next login.".into()
} else {
"Autostart removed.".into()
};
}
Err(e) => {
state.service_status = format!("Failed: {e}");
}
}
Task::none()
}
// ── Close dialog ──────────────────────────────────────────────────
Message::CloseRequested(id) => {
state.close_dialog_open = true;
let _ = id;
Task::none()
}
Message::ConfirmQuit => {
if let Ok(mut a) = state.close_action.lock() {
*a = Some(CloseAction::Quit);
}
iced::window::get_oldest().and_then(iced::window::close)
}
Message::ConfirmMinimize => {
if let Ok(mut a) = state.close_action.lock() {
*a = Some(CloseAction::MinimizeToTray);
}
iced::window::get_oldest().and_then(iced::window::close)
}
Message::CancelClose => {
state.close_dialog_open = false;
Task::none()
}
}
}
fn toggle_flag(
config: &mut Config,
lname: &str,
gname: &str,
f: impl FnOnce(&mut crate::config::Game),
) {
for l in config.launchers.iter_mut() {
if l.name == lname {
for g in l.games.iter_mut() {
if g.name == gname {
f(g);
break;
}
}
break;
}
}
let _ = config.save();
}
fn service_is_installed() -> bool {
dirs::home_dir()
.is_some_and(|h| h.join(".config/autostart/umutray.desktop").exists())
}
fn subscription(_: &Dashboard) -> Subscription<Message> {
Subscription::batch([
iced::time::every(Duration::from_secs(2)).map(|_| Message::PollProcesses),
iced::time::every(Duration::from_secs(5)).map(|_| Message::ReloadConfig),
iced::window::close_requests().map(Message::CloseRequested),
])
}
// Palette constants, icon(), section_heading(), btn_accent(), btn_ghost(),
// btn_danger() are imported from crate::theme.
// ── Top-level view ─────────────────────────────────────────────────────────
fn view(state: &Dashboard) -> Element<'_, Message> {
if state.close_dialog_open {
return view_close_dialog();
}
if state.settings_open {
return view_settings(state);
}
// ── Title bar ─────────────────────────────────────────────────────────
let settings_btn = button(icon("\u{f3e2}", 16))
.on_press(Message::ShowSettings)
.style(btn_ghost)
.padding([6, 10]);
let title = container(
row![
column![
text("umutray").size(26).style(|_: &Theme| text::Style {
color: Some(ACCENT),
}),
text("Wine Launcher Manager").size(11).style(|_: &Theme| text::Style {
color: Some(DIM),
}),
].spacing(2),
iced::widget::horizontal_space(),
settings_btn,
]
.align_y(Alignment::Center),
)
.padding(Padding { top: 20.0, right: 24.0, bottom: 6.0, left: 24.0 });
// ── Accent bar under title ────────────────────────────────────────────
let accent_bar = container(Space::new(0, 0))
.width(Length::Fill)
.height(Length::Fixed(2.0))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color { a: 0.25, ..ACCENT })),
..Default::default()
});
let add_btn = button(
row![icon("\u{f64d}", 12), text(" Add Launcher").size(12)]
.align_y(Alignment::Center)
.spacing(4),
)
.on_press(Message::AddLauncher)
.style(btn_ghost)
.padding([6, 14]);
if state.config.launchers.is_empty() {
let body = column![
title,
accent_bar,
container(
column![
Space::new(0, 40),
text("No launchers configured").size(16).style(|_: &Theme| text::Style {
color: Some(DIM),
}),
text("Add a launcher to get started.").size(12).style(|_: &Theme| text::Style {
color: Some(MUTED),
}),
Space::new(0, 12),
add_btn,
]
.spacing(6)
.align_x(Alignment::Center),
)
.width(Length::Fill)
.center_x(Length::Fill),
];
return container(body)
.width(Length::Fill)
.height(Length::Fill)
.style(surface_bg)
.into();
}
// ── Launcher cards ────────────────────────────────────────────────────
let mut cards: Vec<Element<Message>> = Vec::new();
for l in &state.config.launchers {
let installed = l.full_exe_path().exists();
let running = *state.running.get(&l.name).unwrap_or(&false);
let diagnose_open = state.diagnose_open.as_deref() == Some(&l.name);
let menu_open = state.context_menu.as_deref() == Some(&l.name);
let card_inner: Element<Message> = if diagnose_open {
let checks = state
.diagnose_result
.as_ref()
.filter(|(n, _)| n == &l.name)
.map(|(_, v)| v.as_slice());
diagnose_card(l, checks)
} else if menu_open {
context_menu_card(l)
} else {
launcher_card(
l,
installed,
running,
state.launch_busy.contains(&l.name),
state.adding_game.get(&l.name),
state.scan_results.get(&l.name),
state.scan_busy.contains(&l.name),
)
};
let card = mouse_area(
container(card_inner)
.padding([16, 20])
.style(card_style)
.width(Length::Fill),
)
.on_right_press(Message::ShowContextMenu(l.name.clone()));
cards.push(card.into());
}
// ── Footer ────────────────────────────────────────────────────────────
let proton_btn = button(
row![
icon(if state.proton_busy { "\u{f130}" } else { "\u{f1c8}" }, 12),
text(if state.proton_busy { " Updating…" } else { " GE-Proton" }).size(11),
].align_y(Alignment::Center).spacing(3),
)
.on_press_maybe((!state.proton_busy).then_some(Message::UpdateProton))
.style(btn_ghost)
.padding([5, 12]);
let detect_btn = button(
row![
icon(if state.detect_busy { "\u{f130}" } else { "\u{f52a}" }, 12),
text(if state.detect_busy { " Scanning…" } else { " Detect" }).size(11),
].align_y(Alignment::Center).spacing(3),
)
.on_press_maybe((!state.detect_busy).then_some(Message::DetectPressed))
.style(btn_ghost)
.padding([5, 12]);
let footer_status: Element<Message> = {
let txt = if !state.proton_status.is_empty() {
state.proton_status.clone()
} else if !state.detect_result.is_empty() {
state.detect_result.clone()
} else {
String::new()
};
text(txt).size(10).style(|_: &Theme| text::Style { color: Some(DIM) }).into()
};
let footer = container(
row![
detect_btn,
proton_btn,
add_btn,
iced::widget::horizontal_space(),
footer_status,
]
.align_y(Alignment::Center)
.spacing(6),
)
.padding(Padding { top: 10.0, right: 24.0, bottom: 14.0, left: 24.0 })
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color { r: 0.10, g: 0.11, b: 0.13, a: 1.0 })),
border: Border {
color: BORDER_CLR,
width: 0.0,
radius: 0.0.into(),
},
..Default::default()
});
let cards_col = Column::with_children(cards)
.spacing(10)
.padding(Padding {
top: 12.0,
right: 24.0,
bottom: 12.0,
left: 24.0,
})
.width(Length::Fill);
let error_bar: Element<Message> = if let Some(err) = &state.last_error {
container(
row![
icon("\u{f33b}", 13)
.style(|_: &Theme| text::Style { color: Some(RED) }),
text(format!(" {err}")).size(12)
.style(|_: &Theme| text::Style { color: Some(RED) }),
]
.align_y(Alignment::Center),
)
.padding([6, 24])
.width(Length::Fill)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color { r: 0.97, g: 0.44, b: 0.44, a: 0.08 })),
..Default::default()
})
.into()
} else {
Space::new(0, 0).into()
};
let body = column![
title,
accent_bar,
scrollable(cards_col).height(Length::Fill),
error_bar,
footer,
];
container(body)
.width(Length::Fill)
.height(Length::Fill)
.style(surface_bg)
.into()
}
// ── Card views ──────────────────────────────────────────────────────────────
/// Styled inner container for sub-sections inside a card.
fn sub_card<'a>(content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
container(content)
.padding(Padding::from([10, 14]))
.width(Length::Fill)
.style(sub_card_style)
.into()
}
fn launcher_card<'a>(
l: &'a crate::config::Launcher,
installed: bool,
running: bool,
launching: bool,
add_form: Option<&'a (String, String)>,
scan_results: Option<&'a Vec<(String, String)>>,
scan_busy: bool,
) -> Element<'a, Message> {
let status_color = if running { GREEN } else if launching || installed { ACCENT } else { MUTED };
let status_icon = if running { "\u{f287}" } else if launching { "\u{f130}" } else if installed { "\u{f26a}" } else { "\u{f28a}" };
let status_str = if running { "Running" } else if launching { "Launching…" } else if installed { "Ready" } else { "Not installed" };
// ── Action button ─────────────────────────────────────────────────────
let action: Element<Message> = {
let n = l.name.clone();
if !installed {
button(
row![icon("\u{f3e2}", 13), text(" Setup").size(12)]
.align_y(Alignment::Center).spacing(4),
)
.on_press(Message::Setup(n))
.style(btn_ghost)
.padding([7, 14])
.into()
} else if launching {
button(
row![icon("\u{f130}", 13), text(" Launching…").size(12)]
.align_y(Alignment::Center).spacing(4),
)
.style(btn_ghost)
.padding([7, 14])
.into()
} else if running {
button(
row![icon("\u{f5de}", 13), text(" Stop").size(12)]
.align_y(Alignment::Center).spacing(4),
)
.on_press(Message::Kill(n))
.style(btn_danger)
.padding([7, 14])
.into()
} else {
button(
row![icon("\u{f4f4}", 13), text(" Launch").size(12)]
.align_y(Alignment::Center).spacing(4),
)
.on_press(Message::Launch(n))
.style(btn_accent)
.padding([7, 14])
.into()
}
};
// ── Proton version badge ──────────────────────────────────────────────
let version_badge: Element<Message> = if let Some(v) = &l.proton_version {
container(
text(v).size(10).style(move |_: &Theme| text::Style {
color: Some(ACCENT),
})
)
.padding(Padding::from([3, 8]))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color { r: 0.49, g: 0.55, b: 0.97, a: 0.10 })),
border: Border { color: Color { a: 0.20, ..ACCENT }, width: 1.0, radius: 10.0.into() },
..Default::default()
})
.into()
} else {
Space::new(0, 0).into()
};
// ── Status pill ───────────────────────────────────────────────────────
let status_pill = container(
row![
icon(status_icon, 9).style(move |_: &Theme| text::Style { color: Some(status_color) }),
text(format!(" {status_str}")).size(10)
.style(move |_: &Theme| text::Style { color: Some(status_color) }),
].align_y(Alignment::Center),
)
.padding(Padding::from([3, 8]))
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(Color { a: 0.10, ..status_color })),
border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 10.0.into() },
..Default::default()
});
// ── Header ────────────────────────────────────────────────────────────
let header = row![
column![
row![
text(&l.display).size(18),
version_badge,
].align_y(Alignment::Center).spacing(8),
row![
status_pill,
].align_y(Alignment::Center),
].spacing(6),
iced::widget::horizontal_space(),
action,
]
.align_y(Alignment::Center);
let mut sections: Vec<Element<Message>> = vec![header.into()];
// ── Games section ─────────────────────────────────────────────────────
let has_games = !l.games.is_empty();
let has_scan = scan_results.is_some_and(|r| !r.is_empty());
if has_games || has_scan || scan_busy {
let game_count = l.games.len();
let section_label: String = if !has_games {
"DETECTED".to_string()
} else if game_count == 1 {
"1 GAME".to_string()
} else {
format!("{game_count} GAMES")
};
let lname_scan = l.name.clone();
let rescan_btn = button(icon("\u{f130}", 11))
.on_press_maybe((!scan_busy).then_some(Message::ScanGames(lname_scan)))
.style(btn_ghost)
.padding([4, 7]);
let section_header = row![
text(section_label).size(10)
.style(move |_: &Theme| text::Style { color: Some(DIM) }),
iced::widget::horizontal_space(),
rescan_btn,
]
.align_y(Alignment::Center);
let mut game_items: Vec<Element<Message>> = vec![section_header.into()];
// Scanning indicator
if scan_busy {
game_items.push(
container(
row![
icon("\u{f130}", 12)
.style(move |_: &Theme| text::Style { color: Some(ACCENT) }),
text(" Scanning for games…").size(12)
.style(move |_: &Theme| text::Style { color: Some(ACCENT) }),
].align_y(Alignment::Center),
)
.padding(Padding::from([8, 0]))
.into(),
);
}
// Detected (unregistered) games
if let Some(results) = scan_results {
for (display, exe) in results {
let lname = l.name.clone();
let d = display.clone();
let e = exe.clone();
let add_btn = button(
row![icon("\u{f64d}", 11), text(" Add").size(11)]
.align_y(Alignment::Center).spacing(3),
)
.on_press(Message::AddScannedGame(lname, d, e))
.style(btn_accent)
.padding([5, 10]);
game_items.push(sub_card(
row![
text("").size(10).style(move |_: &Theme| text::Style { color: Some(DIM) }),
column![
text(display).size(13),
text(exe).size(9).style(move |_: &Theme| text::Style { color: Some(MUTED) }),
].spacing(2),
iced::widget::horizontal_space(),
add_btn,
]
.align_y(Alignment::Center)
.spacing(10),
));
}
if results.is_empty() && !scan_busy {
game_items.push(
container(
text("No additional games detected.").size(11)
.style(move |_: &Theme| text::Style { color: Some(MUTED) })
)
.padding(Padding::from([6, 0]))
.into(),
);
}
}
// Configured games
for g in &l.games {
let lname = l.name.clone();
let gname = g.name.clone();
let game_installed = g.full_exe_path(l).exists();
// Play button
let play = button(icon("\u{f4f4}", 14))
.on_press_maybe(game_installed.then_some(Message::Play(lname.clone(), gname.clone())))
.style(btn_accent)
.padding([7, 11]);
// Status dot
let status_dot_color = if game_installed { GREEN } else { MUTED };
let status_dot = text("").size(7).style(move |_: &Theme| text::Style {
color: Some(status_dot_color),
});
// Overlay toggle pills
let gm_active = g.gamemode;
let hud_active = g.mangohud;
let gs_active = g.gamescope.is_some();
let pill = |label: &'static str, active: bool, msg: Message| -> Element<'a, Message> {
let (fg, bg_alpha, border_alpha) = if active {
(ACCENT, 0.15, 0.30)
} else {
(MUTED, 0.0, 0.10)
};
button(
text(label).size(9).style(move |_: &Theme| text::Style { color: Some(fg) })
)
.on_press(msg)
.style(move |_: &Theme, _status| button::Style {
background: Some(Background::Color(Color { r: 0.49, g: 0.55, b: 0.97, a: bg_alpha })),
text_color: fg,
border: Border {
color: Color { a: border_alpha, ..ACCENT },
width: 1.0,
radius: 10.0.into(),
},
shadow: NO_SHADOW,
})
.padding([3, 9])
.into()
};
let overlays = row![
pill("GM", gm_active, Message::ToggleGameMode(lname.clone(), gname.clone())),
pill("HUD", hud_active, Message::ToggleMangoHud(lname.clone(), gname.clone())),
pill("GS", gs_active, Message::ToggleGamescope(lname.clone(), gname.clone())),
].spacing(4);
// Remove
let remove_game = button(icon("\u{f659}", 11))
.on_press(Message::RemoveGame(lname, gname))
.style(btn_danger)
.padding([4, 7]);
game_items.push(sub_card(
row![
play,
container(status_dot).padding(Padding { top: 0.0, left: 2.0, right: 2.0, bottom: 0.0 }),
text(&g.display).size(14),
iced::widget::horizontal_space(),
overlays,
container(remove_game).padding(Padding { top: 0.0, left: 8.0, right: 0.0, bottom: 0.0 }),
]
.align_y(Alignment::Center)
.spacing(8),
));
}
sections.push(
Column::with_children(game_items).spacing(6).into(),
);
} else {
// Empty state
let lname = l.name.clone();
let lname2 = l.name.clone();
let scan_btn = button(
row![icon("\u{f130}", 11), text(" Scan for games").size(11)]
.align_y(Alignment::Center).spacing(4),
)
.on_press(Message::ScanGames(lname))
.style(btn_ghost)
.padding([5, 12]);
let browse_btn = button(
row![icon("\u{f3e8}", 11), text(" Browse…").size(11)]
.align_y(Alignment::Center).spacing(4),
)
.on_press(Message::BrowseGameExe(lname2))
.style(btn_ghost)
.padding([5, 12]);
sections.push(sub_card(
row![
text("No games configured").size(12)
.style(move |_: &Theme| text::Style { color: Some(MUTED) }),
iced::widget::horizontal_space(),
scan_btn,
browse_btn,
]
.align_y(Alignment::Center)
.spacing(6),
));
}
// ── Add game form ─────────────────────────────────────────────────────
if let Some((name_val, exe_val)) = add_form {
let lname = l.name.clone();
let lname2 = l.name.clone();
let lname3 = l.name.clone();
let lname4 = l.name.clone();
let lname5 = l.name.clone();
let name_input = text_input("Game name", name_val)
.on_input(move |v| Message::AddGameNameChanged(lname.clone(), v))
.padding(8)
.size(12)
.width(Length::FillPortion(2));
let exe_input = text_input("Exe path (relative to drive_c/)", exe_val)
.on_input(move |v| Message::AddGameExeChanged(lname2.clone(), v))
.padding(8)
.size(12)
.width(Length::FillPortion(3));
let browse_btn = button(text("Browse…").size(11))
.on_press(Message::BrowseGameExe(lname5))
.style(btn_ghost)
.padding([6, 10]);
let can_confirm = !name_val.trim().is_empty() && !exe_val.trim().is_empty();
let confirm_btn = button(
row![icon("\u{f64d}", 11), text(" Add Game").size(11)]
.align_y(Alignment::Center).spacing(3),
)
.on_press_maybe(can_confirm.then_some(Message::AddGameConfirm(lname3)))
.style(btn_accent)
.padding([6, 12]);
let cancel_btn = button(text("Cancel").size(11))
.on_press(Message::AddGamePressed(lname4))
.style(btn_ghost)
.padding([6, 10]);
sections.push(sub_card(
column![
section_heading("Add Game"),
row![name_input, exe_input, browse_btn]
.align_y(Alignment::Center)
.spacing(6),
container(
row![cancel_btn, confirm_btn].spacing(6),
)
.width(Length::Fill)
.align_x(Alignment::End),
]
.spacing(8),
));
}
// ── Toolbar ───────────────────────────────────────────────────────────
let lname_add = l.name.clone();
let lname_scan = l.name.clone();
let lname_browse = l.name.clone();
let add_label = if add_form.is_some() { "Cancel" } else { "+ Add" };
let add_game_btn = button(text(add_label).size(10))
.on_press(Message::AddGamePressed(lname_add))
.style(btn_ghost)
.padding([4, 9]);
let scan_btn = button(icon("\u{f130}", 10))
.on_press_maybe((!scan_busy).then_some(Message::ScanGames(lname_scan)))
.style(btn_ghost)
.padding([4, 7]);
let browse_btn = button(icon("\u{f3e8}", 10))
.on_press(Message::BrowseGameExe(lname_browse))
.style(btn_ghost)
.padding([4, 7]);
sections.push(
row![
add_game_btn,
iced::widget::horizontal_space(),
scan_btn,
browse_btn,
]
.align_y(Alignment::Center)
.spacing(4)
.into(),
);
Column::with_children(sections).spacing(12).into()
}
fn context_menu_card(l: &crate::config::Launcher) -> Element<'_, Message> {
let header = row![
column![
text(&l.display).size(16),
text("ACTIONS").size(10).style(|_: &Theme| text::Style {
color: Some(DIM),
}),
]
.spacing(3),
iced::widget::horizontal_space(),
button(icon("\u{f659}", 13))
.on_press(Message::HideContextMenu)
.style(btn_ghost)
.padding([5, 8]),
]
.align_y(Alignment::Center);
let menu_btn = |label: &str, ico: &str, msg: Message| -> Element<'_, Message> {
button(
row![
icon(ico, 13),
text(format!(" {label}")).size(13),
].align_y(Alignment::Center).spacing(6),
)
.on_press(msg)
.style(btn_ghost)
.padding([8, 14])
.width(Length::Fill)
.into()
};
let remove = button(
row![
icon("\u{f659}", 13),
text(" Remove launcher").size(13),
].align_y(Alignment::Center).spacing(6),
)
.on_press(Message::RemoveLauncher(l.name.clone()))
.style(btn_danger)
.padding([8, 14])
.width(Length::Fill);
column![
header,
Space::new(0, 4),
menu_btn("Open install folder", "\u{f3e8}", Message::OpenPrefix(l.name.clone())),
menu_btn("Re-run setup wizard", "\u{f130}", Message::RerunSetup(l.name.clone())),
menu_btn("Run diagnostics", "\u{f52a}", Message::DiagnosePressed(l.name.clone())),
Space::new(0, 2),
remove,
]
.spacing(2)
.into()
}
fn diagnose_card<'a>(
l: &'a crate::config::Launcher,
checks: Option<&'a [diagnose::CheckResult]>,
) -> Element<'a, Message> {
let header = row![
column![
text("Diagnostics").size(16),
text(&l.display).size(11).style(|_: &Theme| text::Style {
color: Some(ACCENT),
}),
]
.spacing(3),
iced::widget::horizontal_space(),
button(icon("\u{f659}", 13))
.on_press(Message::HideDiagnose)
.style(btn_ghost)
.padding([5, 8]),
]
.align_y(Alignment::Center);
let body: Element<Message> = match checks {
None => container(
row![
icon("\u{f130}", 13).style(|_: &Theme| text::Style { color: Some(ACCENT) }),
text(" Running checks…").size(12).style(|_: &Theme| text::Style {
color: Some(ACCENT),
}),
].align_y(Alignment::Center),
)
.padding([8, 0])
.into(),
Some(results) => {
let rows: Vec<Element<Message>> = results
.iter()
.map(|c| {
let (sym, color) = if c.pass {
("\u{f26a}", GREEN) // check-circle
} else {
("\u{f28a}", RED) // x-circle
};
sub_card(
row![
icon(sym, 13).style(move |_: &Theme| text::Style {
color: Some(color),
}),
text(format!(" {}", c.label)).size(12),
iced::widget::horizontal_space(),
text(&c.detail).size(11).style(|_: &Theme| text::Style {
color: Some(DIM),
}),
]
.align_y(Alignment::Center),
)
})
.collect();
scrollable(Column::with_children(rows).spacing(4))
.height(Length::Fixed(180.0))
.into()
}
};
column![
header,
Space::new(0, 6),
body,
]
.spacing(4)
.into()
}
/// Styled section card for the settings page.
fn settings_section<'a>(title: &str, content: Element<'a, Message>) -> Element<'a, Message> {
container(
column![
text(title.to_uppercase()).size(10).style(|_: &Theme| text::Style {
color: Some(DIM),
}),
Space::new(0, 4),
content,
]
.spacing(2),
)
.padding([14, 18])
.width(Length::Fill)
.style(card_style)
.into()
}
// ── Close dialog ────────────────────────────────────────────────────────────
fn view_close_dialog() -> Element<'static, Message> {
use crate::theme::card_style;
let title_row = row![
icon("\u{f623}", 18)
.style(|_: &Theme| text::Style { color: Some(ACCENT) }),
text(" Close umutray?").size(16),
]
.align_y(Alignment::Center);
let description = text("Choose what happens when you close the window.")
.size(12)
.style(|_: &Theme| text::Style { color: Some(DIM) });
let minimize_btn = button(
row![
icon("\u{f393}", 14),
text(" Minimize to Tray").size(13),
]
.align_y(Alignment::Center),
)
.on_press(Message::ConfirmMinimize)
.style(btn_accent)
.padding([10, 20])
.width(Length::Fill);
let quit_btn = button(
row![
icon("\u{f1c3}", 14),
text(" Quit").size(13),
]
.align_y(Alignment::Center),
)
.on_press(Message::ConfirmQuit)
.style(btn_danger)
.padding([10, 20])
.width(Length::Fill);
let cancel_btn = button(text("Cancel").size(12))
.on_press(Message::CancelClose)
.style(btn_ghost)
.padding([8, 16])
.width(Length::Fill);
let dialog = container(
column![
title_row,
description,
Space::new(0, 8),
minimize_btn,
quit_btn,
cancel_btn,
]
.spacing(10)
.width(Length::Fixed(280.0)),
)
.padding(24)
.style(card_style);
// Centre the dialog on a dark backdrop.
container(
container(dialog)
.center_x(Length::Fill)
.center_y(Length::Fill),
)
.width(Length::Fill)
.height(Length::Fill)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color { r: 0.0, g: 0.0, b: 0.0, a: 0.65 })),
..Default::default()
})
.into()
}
// ── Settings view ───────────────────────────────────────────────────────────
fn view_settings(state: &Dashboard) -> Element<'_, Message> {
let header = row![
row![
icon("\u{f3e2}", 20).style(|_: &Theme| text::Style { color: Some(ACCENT) }),
text(" Settings").size(22),
].align_y(Alignment::Center),
iced::widget::horizontal_space(),
button(
row![icon("\u{f12f}", 13), text(" Back").size(12)]
.align_y(Alignment::Center).spacing(4),
)
.on_press(Message::HideSettings)
.style(btn_ghost)
.padding([6, 14]),
]
.align_y(Alignment::Center);
// ── Proton section ────────────────────────────────────────────────────
let proton_version_picker = pick_list(
state.proton_versions.as_slice(),
Some(if state.settings_proton_version == "GE-Proton" {
"GE-Proton (latest)".to_string()
} else {
state.settings_proton_version.clone()
}),
Message::SettingsProtonVersionChanged,
)
.width(Length::Fill);
let compat_dir_input = text_input(
"e.g. ~/.local/share/Steam/compatibilitytools.d",
&state.settings_compat_dir,
)
.on_input(Message::SettingsCompatDirChanged)
.padding(10)
.width(Length::Fill);
let browse_compat_btn = button(text("Browse…").size(12))
.on_press(Message::BrowseCompatDir)
.style(btn_ghost)
.padding([8, 14]);
let save_btn = button(
row![icon("\u{f26a}", 12), text(" Save").size(12)]
.align_y(Alignment::Center).spacing(4),
)
.on_press(Message::SaveSettings)
.style(btn_accent)
.padding([8, 18]);
let proton_section = settings_section("Proton",
column![
text("Version").size(12).style(|_: &Theme| text::Style { color: Some(DIM) }),
proton_version_picker,
Space::new(0, 4),
text("Compat directory").size(12).style(|_: &Theme| text::Style { color: Some(DIM) }),
row![compat_dir_input, browse_compat_btn].spacing(8).align_y(Alignment::Center),
Space::new(0, 4),
container(save_btn).width(Length::Fill).align_x(Alignment::End),
].spacing(6).into(),
);
// ── Tools section ─────────────────────────────────────────────────────
let tools_section = settings_section("Tools",
column![
button(
row![icon("\u{f3e2}", 13), text(" Launch Protontricks").size(12)]
.align_y(Alignment::Center).spacing(6),
)
.on_press(Message::LaunchProtontricks)
.style(btn_ghost)
.padding([8, 14]),
].into(),
);
// ── Autostart section ─────────────────────────────────────────────────
let installed = service_is_installed();
let svc_status_color = if installed { GREEN } else { MUTED };
let svc_status_text = if installed { "Enabled — starts on login" } else { "Disabled" };
let svc_status_icon = if installed { "\u{f26a}" } else { "\u{f28a}" };
let svc_install_btn = button(
row![icon("\u{f64d}", 12), text(if state.service_busy { " Working…" } else { " Enable" }).size(12)]
.align_y(Alignment::Center).spacing(4),
)
.on_press_maybe((!state.service_busy && !installed).then_some(Message::ServiceInstall))
.style(btn_accent)
.padding([7, 14]);
let svc_uninstall_btn = button(
row![icon("\u{f659}", 12), text(if state.service_busy { " Working…" } else { " Disable" }).size(12)]
.align_y(Alignment::Center).spacing(4),
)
.on_press_maybe((!state.service_busy && installed).then_some(Message::ServiceUninstall))
.style(btn_danger)
.padding([7, 14]);
let autostart_section = settings_section("Autostart",
column![
row![
icon(svc_status_icon, 13).style(move |_: &Theme| text::Style { color: Some(svc_status_color) }),
text(format!(" {svc_status_text}")).size(12).style(move |_: &Theme| text::Style {
color: Some(svc_status_color),
}),
iced::widget::horizontal_space(),
svc_install_btn,
svc_uninstall_btn,
].align_y(Alignment::Center).spacing(8),
text(&state.service_status).size(11).style(|_: &Theme| text::Style {
color: Some(DIM),
}),
].spacing(6).into(),
);
// ── Error bar ─────────────────────────────────────────────────────────
let error_el: Element<Message> = if let Some(err) = &state.last_error {
container(
row![
icon("\u{f33b}", 13)
.style(|_: &Theme| text::Style { color: Some(RED) }),
text(format!(" {err}")).size(12)
.style(|_: &Theme| text::Style { color: Some(RED) }),
].align_y(Alignment::Center),
)
.padding([8, 18])
.width(Length::Fill)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color { a: 0.08, ..RED })),
border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 8.0.into() },
..Default::default()
})
.into()
} else {
Space::new(0, 0).into()
};
let body = column![
header,
Space::new(0, 6),
proton_section,
tools_section,
autostart_section,
error_el,
]
.spacing(12)
.padding(24);
container(scrollable(body))
.width(Length::Fill)
.height(Length::Fill)
.style(surface_bg)
.into()
}
pub fn run(config: &Config) -> Result<CloseAction> {
let config = config.clone();
let close_action: Arc<Mutex<Option<CloseAction>>> = Arc::new(Mutex::new(None));
let ca = close_action.clone();
iced::application(|_: &Dashboard| String::from("umutray"), update, view)
.subscription(subscription)
.theme(|_| Theme::Dark)
.window(iced::window::Settings {
size: iced::Size::new(640.0, 600.0),
min_size: Some(iced::Size::new(480.0, 400.0)),
exit_on_close_request: false,
..Default::default()
})
.run_with(move || {
let cfg = config.clone();
let load_font = iced::font::load(std::borrow::Cow::Borrowed(iced_fonts::BOOTSTRAP_FONT_BYTES)).map(|_| Message::FontLoaded);
(Dashboard::new(cfg, ca.clone()), load_font)
})
.map_err(|e| anyhow::anyhow!("iced: {e}"))?;
let action = close_action.lock().unwrap().unwrap_or(CloseAction::Quit);
Ok(action)
}