f645b58470
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1185 lines
42 KiB
Rust
1185 lines
42 KiB
Rust
use crate::{config::Config, detect, diagnose, launcher, proton, service, 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, 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 {
|
||
FontLoaded,
|
||
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>),
|
||
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>),
|
||
}
|
||
|
||
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)>,
|
||
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,
|
||
}
|
||
|
||
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();
|
||
let proton_versions = proton::list_installed(&config);
|
||
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(),
|
||
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(),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
||
match msg {
|
||
Message::FontLoaded => Task::none(),
|
||
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.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.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;
|
||
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).map_err(|e| e.to_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();
|
||
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()
|
||
}
|
||
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 format (allow "GE-Proton (latest)" or "GE-Proton\d+-\d+")
|
||
let version_key = if version == "GE-Proton (latest)" {
|
||
"GE-Proton".to_string()
|
||
} else {
|
||
version.clone()
|
||
};
|
||
let valid_version = version_key == "GE-Proton"
|
||
|| version_key.starts_with("GE-Proton");
|
||
if !valid_version {
|
||
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::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()
|
||
}
|
||
}
|
||
}
|
||
|
||
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()
|
||
.map(|h| h.join(".config/autostart/umutray.desktop").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("\u{f3e2}").font(iced::Font::with_name("bootstrap-icons")).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),
|
||
state.scan_results.get(&l.name),
|
||
state.scan_busy.contains(&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)>,
|
||
scan_results: Option<&'a Vec<(String, String)>>,
|
||
scan_busy: bool,
|
||
) -> Element<'a, Message> {
|
||
let status_color = if running {
|
||
Color::from_rgb(0.4, 0.9, 0.4)
|
||
} else if installed {
|
||
Color::from_rgb(0.55, 0.75, 1.0)
|
||
} else {
|
||
Color::from_rgb(0.5, 0.5, 0.5)
|
||
};
|
||
let status_str = if running { "● Running" } else if installed { "○ Installed" } else { "· Not installed" };
|
||
let status_el: Element<Message> = text(status_str).size(12).style(move |_: &Theme| text::Style {
|
||
color: Some(status_color),
|
||
}).into();
|
||
|
||
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 version_badge: Element<Message> = if let Some(v) = &l.proton_version {
|
||
text(format!(" [{v}]"))
|
||
.size(11)
|
||
.style(|_: &Theme| text::Style {
|
||
color: Some(Color::from_rgb(0.55, 0.75, 1.0)),
|
||
})
|
||
.into()
|
||
} else {
|
||
text("").into()
|
||
};
|
||
|
||
let header = row![
|
||
text(&l.display).size(15),
|
||
text(" ").size(12),
|
||
status_el,
|
||
version_badge,
|
||
iced::widget::horizontal_space(),
|
||
action,
|
||
]
|
||
.align_y(Alignment::Center);
|
||
|
||
let mut rows: Vec<Element<Message>> = vec![header.into()];
|
||
|
||
if l.games.is_empty() && scan_results.map(|r| r.is_empty()).unwrap_or(true) && !scan_busy {
|
||
let lname = l.name.clone();
|
||
let lname2 = l.name.clone();
|
||
let scan_btn = button(text("Scan for games").size(11))
|
||
.on_press(Message::ScanGames(lname))
|
||
.style(button::secondary);
|
||
let browse_btn = button(text("Browse exe…").size(11))
|
||
.on_press(Message::BrowseGameExe(lname2))
|
||
.style(button::secondary);
|
||
rows.push(
|
||
row![
|
||
text(" ").size(12),
|
||
text("No games added yet.").size(12).style(|_: &Theme| text::Style {
|
||
color: Some(Color::from_rgb(0.55, 0.55, 0.55)),
|
||
}),
|
||
iced::widget::horizontal_space(),
|
||
scan_btn,
|
||
browse_btn,
|
||
]
|
||
.align_y(Alignment::Center)
|
||
.spacing(6)
|
||
.into(),
|
||
);
|
||
}
|
||
|
||
if scan_busy {
|
||
rows.push(text(" Scanning…").size(12).into());
|
||
}
|
||
|
||
// Scan results (exes found in prefix, not yet added)
|
||
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(text("+").size(11))
|
||
.on_press(Message::AddScannedGame(lname, d, e))
|
||
.style(button::primary);
|
||
rows.push(
|
||
row![
|
||
text(" ").size(12),
|
||
text(display).size(12).style(|_: &Theme| text::Style {
|
||
color: Some(Color::from_rgb(0.75, 0.75, 0.75)),
|
||
}),
|
||
iced::widget::horizontal_space(),
|
||
add_btn,
|
||
]
|
||
.align_y(Alignment::Center)
|
||
.spacing(4)
|
||
.into(),
|
||
);
|
||
}
|
||
if !results.is_empty() {
|
||
rows.push(
|
||
text(" Found in Wine prefix — click + to add")
|
||
.size(10)
|
||
.style(|_: &Theme| text::Style {
|
||
color: Some(Color::from_rgb(0.45, 0.45, 0.45)),
|
||
})
|
||
.into(),
|
||
);
|
||
} else if !scan_busy {
|
||
rows.push(
|
||
text(" No new executables found in the Wine prefix.")
|
||
.size(11)
|
||
.style(|_: &Theme| text::Style {
|
||
color: Some(Color::from_rgb(0.45, 0.45, 0.45)),
|
||
})
|
||
.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 lname5 = 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 browse_btn = button(text("Browse…").size(11))
|
||
.on_press(Message::BrowseGameExe(lname5))
|
||
.style(button::secondary);
|
||
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, browse_btn, 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![
|
||
row![text("\u{f3e2}").font(iced::Font::with_name("bootstrap-icons")).size(20), text(" Settings").size(24)].align_y(Alignment::Center),
|
||
iced::widget::horizontal_space(),
|
||
button(text("← Back").size(13))
|
||
.on_press(Message::HideSettings)
|
||
.style(button::secondary),
|
||
]
|
||
.align_y(Alignment::Center);
|
||
|
||
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(8)
|
||
.width(Length::Fill);
|
||
|
||
let browse_compat_btn = button(text("Browse…").size(13))
|
||
.on_press(Message::BrowseCompatDir)
|
||
.style(button::secondary);
|
||
|
||
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_picker,
|
||
text("GE-Proton compat directory").size(13),
|
||
row![compat_dir_input, browse_compat_btn].spacing(8).align_y(Alignment::Center),
|
||
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 || {
|
||
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), load_font)
|
||
})
|
||
.map_err(|e| anyhow::anyhow!("iced: {e}"))
|
||
}
|