new file: Makefile

new file:   TODO.md
	modified:   src/config.rs
	modified:   src/detect.rs
	modified:   src/diagnose.rs
	new file:   src/gui.rs
	modified:   src/main.rs
	modified:   src/service.rs
	modified:   src/setup.rs
	modified:   src/tray.rs
	new file:   src/util.rs
	new file:   umutray.desktop
This commit is contained in:
funman300
2026-04-17 23:12:47 -07:00
parent 4c918e673b
commit f2f584febf
12 changed files with 1471 additions and 113 deletions
+941
View File
@@ -0,0 +1,941 @@
use crate::{config::Config, detect, diagnose, launcher, service, util::async_blocking};
use anyhow::Result;
use iced::widget::{
button, column, container, mouse_area, row, scrollable, text, text_input, Column,
};
use iced::{
Alignment, Background, Border, Color, Element, Length, Padding, Subscription, Task, Theme,
};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
#[derive(Debug, Clone)]
pub enum Message {
PollProcesses,
ReloadConfig,
AddLauncher,
Launch(String),
Kill(String),
Play(String, String),
Setup(String),
ToggleGameMode(String, String),
ToggleMangoHud(String, String),
ToggleGamescope(String, String),
UpdateProton,
ProtonDone(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),
// Settings
ShowSettings,
HideSettings,
SettingsProtonVersionChanged(String),
SettingsCompatDirChanged(String),
SaveSettings,
ServiceInstall,
ServiceUninstall,
ServiceActionDone(Result<(), String>),
}
struct Dashboard {
config: Config,
running: HashMap<String, bool>,
proton_busy: bool,
proton_status: String,
last_error: Option<String>,
context_menu: Option<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)>,
// Settings
settings_open: bool,
settings_proton_version: String,
settings_compat_dir: String,
service_busy: bool,
service_status: String,
}
impl Dashboard {
fn new(config: Config) -> 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();
Self {
config,
running,
proton_busy: false,
proton_status: String::new(),
last_error: None,
context_menu: None,
detect_busy: false,
detect_result: String::new(),
diagnose_open: None,
diagnose_result: None,
adding_game: HashMap::new(),
settings_open: false,
settings_proton_version,
settings_compat_dir,
service_busy: false,
service_status: String::new(),
}
}
}
fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
match msg {
Message::PollProcesses => {
for l in &state.config.launchers {
state.running.insert(l.name.clone(), launcher::is_running(l));
}
Task::none()
}
Message::ReloadConfig => {
if let Ok(fresh) = Config::load() {
state
.running
.retain(|k, _| fresh.launchers.iter().any(|l| &l.name == k));
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.running.insert(name.clone(), true);
let Some(l) = state.config.find(&name) else {
return Task::none();
};
let config = state.config.clone();
let l = l.clone();
std::thread::spawn(move || {
if let Err(e) = launcher::launch(&config, &l) {
eprintln!("umutray: launch {name} failed: {e}");
}
});
Task::none()
}
Message::Kill(name) => {
state.last_error = None;
if let Some(l) = state.config.find(&name) {
let l = l.clone();
match launcher::kill(&l) {
Ok(()) => {
state.running.insert(name, false);
}
Err(e) => {
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();
std::thread::spawn(move || {
if let Err(e) = launcher::play_game(&config, &l, &g) {
eprintln!("umutray: play {gname} 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!("{} (not in config — use Setup to add)", h.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) => {
if state.adding_game.contains_key(&lname) {
state.adding_game.remove(&lname);
} else {
state.adding_game.insert(lname, (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()
}
// ── 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.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::SaveSettings => {
state.last_error = None;
let version = state.settings_proton_version.trim().to_string();
let compat = PathBuf::from(state.settings_compat_dir.trim());
match state.config.set_globals(Some(version), Some(compat)) {
Ok(()) => {
state.service_status = "Settings saved.".into();
}
Err(e) => {
state.last_error = Some(format!("Save failed: {e}"));
}
}
Task::none()
}
Message::ServiceInstall => {
state.service_busy = true;
state.service_status = "Installing service…".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 service…".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() {
"Service installed — autostarts on login.".into()
} else {
"Service removed.".into()
};
}
Err(e) => {
state.service_status = format!("Failed: {e}");
}
}
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 {
std::env::var("HOME")
.ok()
.map(|h| {
PathBuf::from(h)
.join(".config/systemd/user/umutray.service")
.exists()
})
.unwrap_or(false)
}
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),
])
}
// ── Top-level view ─────────────────────────────────────────────────────────
fn view(state: &Dashboard) -> Element<'_, Message> {
if state.settings_open {
return view_settings(state);
}
let settings_btn = button(text("").size(16))
.on_press(Message::ShowSettings)
.style(button::secondary);
let title = container(
row![
text("umutray").size(28),
iced::widget::horizontal_space(),
settings_btn,
]
.align_y(Alignment::Center),
)
.padding(Padding { top: 16.0, right: 20.0, bottom: 8.0, left: 20.0 });
let add_btn = button(text("+ Add Launcher").size(13))
.on_press(Message::AddLauncher)
.style(button::secondary);
if state.config.launchers.is_empty() {
let body = column![
title,
container(
column![text("No launchers configured.").size(15), add_btn,]
.spacing(12)
.padding([20, 20]),
)
.width(Length::Fill),
];
return container(body)
.width(Length::Fill)
.height(Length::Fill)
.into();
}
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.adding_game.get(&l.name))
};
let card = mouse_area(
container(card_inner)
.padding([10, 14])
.style(|theme: &Theme| {
let p = theme.extended_palette();
container::Style {
background: Some(Background::Color(p.background.weak.color)),
border: Border {
color: p.background.strong.color,
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
}
})
.width(Length::Fill),
)
.on_right_press(Message::ShowContextMenu(l.name.clone()));
cards.push(card.into());
}
let proton_btn = button(
text(if state.proton_busy { "Updating…" } else { "Update GE-Proton" }).size(13),
)
.on_press_maybe((!state.proton_busy).then_some(Message::UpdateProton))
.style(button::secondary);
let detect_btn = button(
text(if state.detect_busy { "Scanning…" } else { "Detect Installed" }).size(13),
)
.on_press_maybe((!state.detect_busy).then_some(Message::DetectPressed))
.style(button::secondary);
let footer = container(
column![
row![detect_btn, text(&state.detect_result).size(12),]
.align_y(Alignment::Center)
.spacing(12),
row![proton_btn, text(&state.proton_status).size(12),]
.align_y(Alignment::Center)
.spacing(12),
]
.spacing(6),
)
.padding([10, 20]);
let cards_col = Column::with_children(cards)
.push(container(add_btn).padding(Padding {
top: 8.0,
right: 0.0,
bottom: 0.0,
left: 0.0,
}))
.spacing(8)
.padding(Padding {
top: 0.0,
right: 20.0,
bottom: 8.0,
left: 20.0,
})
.width(Length::Fill);
let error_bar: Element<Message> = if let Some(err) = &state.last_error {
container(
text(format!("{err}"))
.size(12)
.style(|_: &Theme| text::Style {
color: Some(Color::from_rgb(1.0, 0.45, 0.45)),
}),
)
.padding([4, 20])
.width(Length::Fill)
.into()
} else {
text("").into()
};
let body = column![
title,
scrollable(cards_col).height(Length::Fill),
error_bar,
iced::widget::horizontal_rule(1),
footer,
];
container(body)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
// ── Card views ──────────────────────────────────────────────────────────────
fn launcher_card<'a>(
l: &'a crate::config::Launcher,
installed: bool,
running: bool,
add_form: Option<&'a (String, String)>,
) -> Element<'a, Message> {
let status_label = if running {
"● Running"
} else if installed {
"○ Installed"
} else {
"· Not installed"
};
let action: Element<Message> = {
let n = l.name.clone();
if !installed {
button(text("Setup").size(13))
.on_press(Message::Setup(n))
.style(button::secondary)
.into()
} else if running {
button(text("Kill").size(13))
.on_press(Message::Kill(n))
.style(button::danger)
.into()
} else {
button(text("Launch").size(13))
.on_press(Message::Launch(n))
.style(button::primary)
.into()
}
};
let header = row![
text(&l.display).size(15),
text("").size(12),
text(status_label).size(12),
iced::widget::horizontal_space(),
action,
]
.align_y(Alignment::Center);
let mut rows: Vec<Element<Message>> = vec![header.into()];
for g in &l.games {
let lname = l.name.clone();
let gname = g.name.clone();
let play = button(text("").size(11))
.on_press(Message::Play(lname.clone(), gname.clone()))
.style(button::primary);
let gamemode_btn = button(text("GameMode").size(11))
.on_press(Message::ToggleGameMode(lname.clone(), gname.clone()))
.style(if g.gamemode { button::primary } else { button::secondary });
let mangohud_btn = button(text("MangoHud").size(11))
.on_press(Message::ToggleMangoHud(lname.clone(), gname.clone()))
.style(if g.mangohud { button::primary } else { button::secondary });
let gamescope_btn = button(text("Gamescope").size(11))
.on_press(Message::ToggleGamescope(lname.clone(), gname.clone()))
.style(if g.gamescope.is_some() { button::primary } else { button::secondary });
let remove_game = button(text("").size(10))
.on_press(Message::RemoveGame(lname, gname))
.style(button::danger);
rows.push(
row![
text(" ").size(13),
play,
text(&g.display).size(13),
iced::widget::horizontal_space(),
gamemode_btn,
mangohud_btn,
gamescope_btn,
remove_game,
]
.align_y(Alignment::Center)
.spacing(6)
.into(),
);
}
// Inline 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 name_input = text_input("Game name", name_val)
.on_input(move |v| Message::AddGameNameChanged(lname.clone(), v))
.padding(5)
.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(5)
.size(12)
.width(Length::FillPortion(3));
let can_confirm = !name_val.trim().is_empty() && !exe_val.trim().is_empty();
let confirm_btn = button(text("Add").size(11))
.on_press_maybe(can_confirm.then_some(Message::AddGameConfirm(lname3)))
.style(button::primary);
let cancel_btn = button(text("Cancel").size(11))
.on_press(Message::AddGamePressed(lname4))
.style(button::secondary);
rows.push(
row![name_input, exe_input, confirm_btn, cancel_btn,]
.align_y(Alignment::Center)
.spacing(6)
.into(),
);
}
let add_game_btn = {
let lname = l.name.clone();
let label = if add_form.is_some() { " Game" } else { "+ Game" };
button(text(label).size(11))
.on_press(Message::AddGamePressed(lname))
.style(button::secondary)
};
rows.push(
container(add_game_btn)
.padding(Padding { top: 4.0, right: 0.0, bottom: 0.0, left: 0.0 })
.into(),
);
Column::with_children(rows).spacing(6).into()
}
fn context_menu_card(l: &crate::config::Launcher) -> Element<'_, Message> {
let header = row![
text(&l.display).size(15),
iced::widget::horizontal_space(),
button(text("").size(12))
.on_press(Message::HideContextMenu)
.style(button::secondary),
]
.align_y(Alignment::Center);
let open_prefix = button(text("Open prefix folder").size(13))
.on_press(Message::OpenPrefix(l.name.clone()))
.style(button::secondary)
.width(Length::Fill);
let rerun_setup = button(text("Re-run setup").size(13))
.on_press(Message::RerunSetup(l.name.clone()))
.style(button::secondary)
.width(Length::Fill);
let diagnose = button(text("Diagnose").size(13))
.on_press(Message::DiagnosePressed(l.name.clone()))
.style(button::secondary)
.width(Length::Fill);
let remove = button(text("Remove launcher").size(13))
.on_press(Message::RemoveLauncher(l.name.clone()))
.style(button::danger)
.width(Length::Fill);
column![
header,
iced::widget::horizontal_rule(1),
open_prefix,
rerun_setup,
diagnose,
remove,
]
.spacing(4)
.into()
}
fn diagnose_card<'a>(
l: &'a crate::config::Launcher,
checks: Option<&'a [diagnose::CheckResult]>,
) -> Element<'a, Message> {
let header = row![
text(format!("Diagnose: {}", l.display)).size(15),
iced::widget::horizontal_space(),
button(text("").size(12))
.on_press(Message::HideDiagnose)
.style(button::secondary),
]
.align_y(Alignment::Center);
let body: Element<Message> = match checks {
None => text("Running checks…").size(12).into(),
Some(results) => {
let rows: Vec<Element<Message>> = results
.iter()
.map(|c| {
let (sym, color) = if c.pass {
("", Color::from_rgb(0.4, 0.9, 0.4))
} else {
("", Color::from_rgb(1.0, 0.45, 0.45))
};
row![
text(sym).size(12).style(move |_: &Theme| text::Style {
color: Some(color),
}),
text(format!(" {:24} {}", c.label, c.detail)).size(12),
]
.align_y(Alignment::Center)
.into()
})
.collect();
scrollable(Column::with_children(rows).spacing(3))
.height(Length::Fixed(160.0))
.into()
}
};
column![header, iced::widget::horizontal_rule(1), body,]
.spacing(6)
.into()
}
// ── Settings view ───────────────────────────────────────────────────────────
fn view_settings(state: &Dashboard) -> Element<'_, Message> {
let header = row![
text("⚙ Settings").size(24),
iced::widget::horizontal_space(),
button(text("← Back").size(13))
.on_press(Message::HideSettings)
.style(button::secondary),
]
.align_y(Alignment::Center);
let proton_version_input =
text_input("e.g. GE-Proton or GE-Proton10-34", &state.settings_proton_version)
.on_input(Message::SettingsProtonVersionChanged)
.padding(8);
let compat_dir_input = text_input(
"e.g. ~/.local/share/Steam/compatibilitytools.d",
&state.settings_compat_dir,
)
.on_input(Message::SettingsCompatDirChanged)
.padding(8);
let save_btn = button(text("Save").size(13))
.on_press(Message::SaveSettings)
.style(button::primary);
let installed = service_is_installed();
let svc_install_btn = button(
text(if state.service_busy { "Working…" } else { "Install autostart service" }).size(13),
)
.on_press_maybe((!state.service_busy && !installed).then_some(Message::ServiceInstall))
.style(button::secondary);
let svc_uninstall_btn = button(
text(if state.service_busy { "Working…" } else { "Remove autostart service" }).size(13),
)
.on_press_maybe((!state.service_busy && installed).then_some(Message::ServiceUninstall))
.style(button::danger);
let svc_status_label = if installed {
"Autostart: enabled (tray starts on login)"
} else {
"Autostart: disabled"
};
let body = column![
header,
iced::widget::horizontal_rule(1),
text("Proton version").size(13),
proton_version_input,
text("GE-Proton compat directory").size(13),
compat_dir_input,
save_btn,
iced::widget::horizontal_rule(1),
text(svc_status_label).size(13),
row![svc_install_btn, svc_uninstall_btn,].spacing(10),
text(&state.service_status).size(12),
]
.spacing(10)
.padding(20);
container(body)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
pub fn run(config: &Config) -> Result<()> {
let config = config.clone();
iced::application(|_: &Dashboard| String::from("umutray"), update, view)
.subscription(subscription)
.theme(|_| Theme::Dark)
.window(iced::window::Settings {
size: iced::Size::new(600.0, 560.0),
..Default::default()
})
.run_with(move || (Dashboard::new(config.clone()), Task::none()))
.map_err(|e| anyhow::anyhow!("iced: {e}"))
}