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
+23
View File
@@ -0,0 +1,23 @@
PREFIX ?= $(HOME)/.local
BIN_DIR = $(PREFIX)/bin
APP_DIR = $(PREFIX)/share/applications
.PHONY: build install uninstall
build:
cargo build --release
install: build
install -Dm755 target/release/umutray $(BIN_DIR)/umutray
@mkdir -p $(APP_DIR)
@sed "s|Exec=umutray|Exec=$(BIN_DIR)/umutray|" umutray.desktop > $(APP_DIR)/umutray.desktop
@echo ""
@echo "Installed umutray to $(BIN_DIR)/umutray"
@echo "App menu entry written to $(APP_DIR)/umutray.desktop"
@echo ""
@echo "Optional: run 'umutray service install' to autostart the tray on login."
uninstall:
rm -f $(BIN_DIR)/umutray
rm -f $(APP_DIR)/umutray.desktop
@echo "Uninstalled umutray"
+8
View File
@@ -0,0 +1,8 @@
# Project Tasks
- [ ] automatically detect all wine and proton versions installed and have a drop down selection menu globally and for each launcher entry
- [ ] Change the settings button to a cog wheel icon
- [ ] Overhaul the settings menu
- [ ] Overhaul the main dashboard
- [ ] Prefix Dependancy Manager
- { } A speical option for world of warcraft game installs to let you install and launcher the curse forge mod manager within the world of warcraft prefix. Following the trent of modularity and a simplistic approach
+2 -6
View File
@@ -205,7 +205,7 @@ impl Default for Config {
Self { Self {
proton_compat_dir: default_compat_dir(), proton_compat_dir: default_compat_dir(),
proton_version: default_proton_version(), proton_version: default_proton_version(),
launchers: presets(), launchers: vec![],
} }
} }
} }
@@ -227,11 +227,7 @@ impl Config {
let content = std::fs::read_to_string(&path) let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read config from {path:?}"))?; .with_context(|| format!("Failed to read config from {path:?}"))?;
match toml::from_str::<Self>(&content) { match toml::from_str::<Self>(&content) {
Ok(mut c) => { Ok(c) => {
if c.launchers.is_empty() {
c.launchers = presets();
c.save().context("Failed to write presets")?;
}
Ok(c) Ok(c)
} }
Err(e) => { Err(e) => {
+32
View File
@@ -3,6 +3,38 @@ use anyhow::Result;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct DetectHit {
pub display: String,
pub prefix: PathBuf,
/// True if this launcher is already in config with this exact prefix.
pub configured: bool,
}
/// Scan default Wine prefix locations and return hits against known presets.
pub fn scan_for_gui(config: &Config) -> Vec<DetectHit> {
let roots: Vec<PathBuf> = default_roots().into_iter().filter(|r| r.is_dir()).collect();
let prefixes = scan_prefixes(&roots);
let mut hits = Vec::new();
for preset in crate::config::presets() {
for prefix in &prefixes {
if prefix.join("drive_c").join(&preset.exe_path).exists() {
let configured = config
.find(&preset.name)
.map(|l| l.prefix_dir == *prefix)
.unwrap_or(false);
hits.push(DetectHit {
display: preset.display.clone(),
prefix: prefix.clone(),
configured,
});
break;
}
}
}
hits
}
const MAX_DEPTH: u32 = 3; const MAX_DEPTH: u32 = 3;
pub fn run(config: &Config, extra_dirs: &[PathBuf], apply: bool) -> Result<()> { pub fn run(config: &Config, extra_dirs: &[PathBuf], apply: bool) -> Result<()> {
+42 -46
View File
@@ -4,38 +4,30 @@ use std::os::unix::fs::MetadataExt;
use std::path::Path; use std::path::Path;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
struct Check { #[derive(Debug, Clone)]
label: String, pub struct CheckResult {
pass: bool, pub label: String,
detail: String, pub pass: bool,
pub detail: String,
} }
impl Check { impl CheckResult {
fn pass(label: impl Into<String>, detail: impl Into<String>) -> Self { fn pass(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self { Self { label: label.into(), pass: true, detail: detail.into() }
label: label.into(),
pass: true,
detail: detail.into(),
}
} }
fn fail(label: impl Into<String>, detail: impl Into<String>) -> Self { fn fail(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self { Self { label: label.into(), pass: false, detail: detail.into() }
label: label.into(),
pass: false,
detail: detail.into(),
}
} }
} }
pub fn run(config: &Config, name: Option<&str>) -> Result<()> { pub fn run_checks(config: &Config, name: Option<&str>) -> Result<Vec<CheckResult>> {
let mut checks: Vec<Check> = vec![ let mut checks = vec![
global_umu_check(), global_umu_check(),
global_vulkan_check(), global_vulkan_check(),
global_display_check(), global_display_check(),
compat_dir_check(config), compat_dir_check(config),
wineserver_check(config), wineserver_check(config),
]; ];
let launchers: Vec<&Launcher> = if let Some(n) = name { let launchers: Vec<&Launcher> = if let Some(n) = name {
let l = config let l = config
.find(n) .find(n)
@@ -47,7 +39,11 @@ pub fn run(config: &Config, name: Option<&str>) -> Result<()> {
for l in launchers { for l in launchers {
checks.extend(launcher_checks(l)); checks.extend(launcher_checks(l));
} }
Ok(checks)
}
pub fn run(config: &Config, name: Option<&str>) -> Result<()> {
let checks = run_checks(config, name)?;
let mut issues = 0u32; let mut issues = 0u32;
println!(); println!();
for c in &checks { for c in &checks {
@@ -71,14 +67,14 @@ pub fn run(config: &Config, name: Option<&str>) -> Result<()> {
Ok(()) Ok(())
} }
fn global_umu_check() -> Check { fn global_umu_check() -> CheckResult {
match which("umu-run") { match which("umu-run") {
Some(p) => Check::pass("umu-run", format!("found at {p}")), Some(p) => CheckResult::pass("umu-run", format!("found at {p}")),
None => Check::fail("umu-run", "not found — install umu-launcher"), None => CheckResult::fail("umu-run", "not found — install umu-launcher"),
} }
} }
fn global_vulkan_check() -> Check { fn global_vulkan_check() -> CheckResult {
let ok = Command::new("vulkaninfo") let ok = Command::new("vulkaninfo")
.arg("--summary") .arg("--summary")
.stdout(Stdio::null()) .stdout(Stdio::null())
@@ -87,33 +83,33 @@ fn global_vulkan_check() -> Check {
.map(|s| s.success()) .map(|s| s.success())
.unwrap_or(false); .unwrap_or(false);
if ok { if ok {
Check::pass("vulkan", "vulkaninfo OK") CheckResult::pass("vulkan", "vulkaninfo OK")
} else { } else {
Check::fail( CheckResult::fail(
"vulkan", "vulkan",
"vulkaninfo failed — check GPU drivers / vulkan-tools", "vulkaninfo failed — check GPU drivers / vulkan-tools",
) )
} }
} }
fn global_display_check() -> Check { fn global_display_check() -> CheckResult {
let display = std::env::var("DISPLAY").ok(); let display = std::env::var("DISPLAY").ok();
let wayland = std::env::var("WAYLAND_DISPLAY").ok(); let wayland = std::env::var("WAYLAND_DISPLAY").ok();
match (display, wayland) { match (display, wayland) {
(Some(d), Some(_)) => Check::pass("display", format!("XWayland (DISPLAY={d})")), (Some(d), Some(_)) => CheckResult::pass("display", format!("XWayland (DISPLAY={d})")),
(Some(d), None) => Check::pass("display", format!("X11 (DISPLAY={d})")), (Some(d), None) => CheckResult::pass("display", format!("X11 (DISPLAY={d})")),
(None, Some(_)) => Check::fail( (None, Some(_)) => CheckResult::fail(
"display", "display",
"Wayland session but DISPLAY unset; XWayland needed", "Wayland session but DISPLAY unset; XWayland needed",
), ),
(None, None) => Check::fail("display", "neither DISPLAY nor WAYLAND_DISPLAY set"), (None, None) => CheckResult::fail("display", "neither DISPLAY nor WAYLAND_DISPLAY set"),
} }
} }
fn compat_dir_check(config: &Config) -> Check { fn compat_dir_check(config: &Config) -> CheckResult {
let n = count_ge_proton(&config.proton_compat_dir); let n = count_ge_proton(&config.proton_compat_dir);
if config.proton_version == "GE-Proton" { if config.proton_version == "GE-Proton" {
Check::pass( CheckResult::pass(
"proton", "proton",
format!( format!(
"tracking latest; {n} version(s) in {}", "tracking latest; {n} version(s) in {}",
@@ -123,9 +119,9 @@ fn compat_dir_check(config: &Config) -> Check {
} else { } else {
let path = config.proton_compat_dir.join(&config.proton_version); let path = config.proton_compat_dir.join(&config.proton_version);
if path.exists() { if path.exists() {
Check::pass("proton", format!("{} installed", config.proton_version)) CheckResult::pass("proton", format!("{} installed", config.proton_version))
} else { } else {
Check::fail( CheckResult::fail(
"proton", "proton",
format!( format!(
"{} missing — run: umutray update-proton --version={}", "{} missing — run: umutray update-proton --version={}",
@@ -136,19 +132,19 @@ fn compat_dir_check(config: &Config) -> Check {
} }
} }
fn wineserver_check(config: &Config) -> Check { fn wineserver_check(config: &Config) -> CheckResult {
let count = wineserver_count(); let count = wineserver_count();
if count == 0 { if count == 0 {
return Check::pass("wine procs", "no wineserver running"); return CheckResult::pass("wine procs", "no wineserver running");
} }
let any_running = config.launchers.iter().any(crate::launcher::is_running); let any_running = config.launchers.iter().any(crate::launcher::is_running);
if any_running { if any_running {
Check::pass( CheckResult::pass(
"wine procs", "wine procs",
format!("{count} wineserver process(es); launcher active"), format!("{count} wineserver process(es); launcher active"),
) )
} else { } else {
Check::fail( CheckResult::fail(
"wine procs", "wine procs",
format!("{count} stale wineserver process(es) — try: umutray kill"), format!("{count} stale wineserver process(es) — try: umutray kill"),
) )
@@ -165,12 +161,12 @@ fn wineserver_count() -> usize {
.unwrap_or(0) .unwrap_or(0)
} }
fn launcher_checks(l: &Launcher) -> Vec<Check> { fn launcher_checks(l: &Launcher) -> Vec<CheckResult> {
let mut out = Vec::new(); let mut out = Vec::new();
let tag = format!("[{}]", l.name); let tag = format!("[{}]", l.name);
if !l.prefix_dir.exists() { if !l.prefix_dir.exists() {
out.push(Check::fail( out.push(CheckResult::fail(
format!("{tag} prefix"), format!("{tag} prefix"),
format!( format!(
"{} missing — run: umutray setup {}", "{} missing — run: umutray setup {}",
@@ -180,34 +176,34 @@ fn launcher_checks(l: &Launcher) -> Vec<Check> {
)); ));
return out; return out;
} }
out.push(Check::pass( out.push(CheckResult::pass(
format!("{tag} prefix"), format!("{tag} prefix"),
l.prefix_dir.display().to_string(), l.prefix_dir.display().to_string(),
)); ));
let exe = l.full_exe_path(); let exe = l.full_exe_path();
if exe.exists() { if exe.exists() {
out.push(Check::pass(format!("{tag} exe"), "installed")); out.push(CheckResult::pass(format!("{tag} exe"), "installed"));
} else { } else {
out.push(Check::fail( out.push(CheckResult::fail(
format!("{tag} exe"), format!("{tag} exe"),
format!("missing — run: umutray setup {}", l.name), format!("missing — run: umutray setup {}", l.name),
)); ));
} }
if is_owned_by_current_user(&l.prefix_dir) { if is_owned_by_current_user(&l.prefix_dir) {
out.push(Check::pass(format!("{tag} owner"), "owned by current user")); out.push(CheckResult::pass(format!("{tag} owner"), "owned by current user"));
} else { } else {
out.push(Check::fail( out.push(CheckResult::fail(
format!("{tag} owner"), format!("{tag} owner"),
"not owned by current user", "not owned by current user",
)); ));
} }
if crate::launcher::is_running(l) { if crate::launcher::is_running(l) {
out.push(Check::pass(format!("{tag} process"), "currently running")); out.push(CheckResult::pass(format!("{tag} process"), "currently running"));
} else { } else {
out.push(Check::pass(format!("{tag} process"), "not running")); out.push(CheckResult::pass(format!("{tag} process"), "not running"));
} }
out out
+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}"))
}
+26 -8
View File
@@ -1,11 +1,13 @@
mod config; mod config;
mod detect; mod detect;
mod diagnose; mod diagnose;
mod gui;
mod launcher; mod launcher;
mod proton; mod proton;
mod service; mod service;
mod setup; mod setup;
mod tray; mod tray;
mod util;
use anyhow::Result; use anyhow::Result;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
@@ -62,12 +64,15 @@ enum Commands {
launcher: Option<String>, launcher: Option<String>,
}, },
/// Open the graphical setup wizard for a launcher /// Open the graphical setup wizard. Omit NAME to pick from the launcher list.
Setup { Setup {
/// Launcher name /// Launcher name (e.g. battlenet). Omit to open the launcher picker.
name: String, name: Option<String>,
}, },
/// Open the graphical dashboard (default when launched from app menu)
Gui,
/// Scan common Wine prefix locations for installed launchers /// Scan common Wine prefix locations for installed launchers
Detect { Detect {
/// Additional directory to scan (repeatable) /// Additional directory to scan (repeatable)
@@ -214,12 +219,16 @@ enum ConfigAction {
#[derive(Subcommand)] #[derive(Subcommand)]
enum ServiceAction { enum ServiceAction {
/// Write the unit, daemon-reload, and enable+start the service /// Write the unit, daemon-reload, and enable+start the service (includes app menu entry)
Install, Install,
/// Stop, disable, and remove the unit file /// Stop, disable, and remove the unit file (includes app menu entry)
Uninstall, Uninstall,
/// Show `systemctl --user status` for the service /// Show `systemctl --user status` for the service
Status, Status,
/// Install only the app menu entry — no systemd service required
InstallDesktop,
/// Remove the app menu entry
UninstallDesktop,
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@@ -305,12 +314,19 @@ fn main() -> Result<()> {
} }
} }
Commands::Setup { name } => { Commands::Setup { name } => match name {
let l = config.find(&name).ok_or_else(|| { None => setup::run_new(&config)?,
anyhow::anyhow!("unknown launcher '{name}' — try `umutray launchers`") Some(n) => {
let l = config.find(&n).ok_or_else(|| {
anyhow::anyhow!(
"unknown launcher '{n}' — try `umutray setup` to add it first"
)
})?; })?;
setup::run(&config, l)?; setup::run(&config, l)?;
} }
},
Commands::Gui => gui::run(&config)?,
Commands::Detect { dir, apply } => { Commands::Detect { dir, apply } => {
detect::run(&config, &dir, apply)?; detect::run(&config, &dir, apply)?;
@@ -402,6 +418,8 @@ fn main() -> Result<()> {
ServiceAction::Install => service::install()?, ServiceAction::Install => service::install()?,
ServiceAction::Uninstall => service::uninstall()?, ServiceAction::Uninstall => service::uninstall()?,
ServiceAction::Status => service::status()?, ServiceAction::Status => service::status()?,
ServiceAction::InstallDesktop => service::install_desktop()?,
ServiceAction::UninstallDesktop => service::uninstall_desktop()?,
}, },
} }
+79 -24
View File
@@ -3,18 +3,28 @@ use std::path::PathBuf;
use std::process::Command; use std::process::Command;
const UNIT_NAME: &str = "umutray.service"; const UNIT_NAME: &str = "umutray.service";
const DESKTOP_NAME: &str = "umutray.desktop";
fn home() -> Result<PathBuf> {
Ok(PathBuf::from(
std::env::var("HOME").context("$HOME is not set")?,
))
}
fn unit_path() -> Result<PathBuf> { fn unit_path() -> Result<PathBuf> {
let home = std::env::var("HOME").context("$HOME is not set")?; Ok(home()?.join(".config/systemd/user").join(UNIT_NAME))
Ok(PathBuf::from(home) }
.join(".config/systemd/user")
.join(UNIT_NAME)) fn desktop_path() -> Result<PathBuf> {
Ok(home()?
.join(".local/share/applications")
.join(DESKTOP_NAME))
} }
fn render_unit(exe: &std::path::Path) -> String { fn render_unit(exe: &std::path::Path) -> String {
format!( format!(
"[Unit]\n\ "[Unit]\n\
Description=Battle.net tray manager\n\ Description=umutray Wine launcher manager\n\
After=graphical-session.target\n\ After=graphical-session.target\n\
PartOf=graphical-session.target\n\ PartOf=graphical-session.target\n\
\n\ \n\
@@ -29,6 +39,21 @@ fn render_unit(exe: &std::path::Path) -> String {
) )
} }
fn render_desktop(exe: &std::path::Path) -> String {
format!(
"[Desktop Entry]\n\
Name=umutray\n\
Comment=Wine launcher manager for Windows game launchers\n\
Exec={exe} gui\n\
Icon=applications-games\n\
Type=Application\n\
Categories=Game;\n\
Keywords=wine;proton;gaming;launcher;\n\
StartupNotify=true\n",
exe = exe.display(),
)
}
fn systemctl(args: &[&str]) -> Result<()> { fn systemctl(args: &[&str]) -> Result<()> {
let status = Command::new("systemctl") let status = Command::new("systemctl")
.arg("--user") .arg("--user")
@@ -41,20 +66,49 @@ fn systemctl(args: &[&str]) -> Result<()> {
Ok(()) Ok(())
} }
/// Write the unit, reload systemd, and enable+start the service. /// Install only the .desktop file so umutray appears in the app menu.
pub fn install() -> Result<()> { /// Called automatically on first `umutray gui` run.
pub fn install_desktop() -> Result<()> {
let exe = std::env::current_exe().context("Cannot determine path to own executable")?; let exe = std::env::current_exe().context("Cannot determine path to own executable")?;
let path = unit_path()?; let desktop = desktop_path()?;
if let Some(p) = desktop.parent() {
if let Some(parent) = path.parent() { std::fs::create_dir_all(p).with_context(|| format!("Failed to create {p:?}"))?;
std::fs::create_dir_all(parent).with_context(|| format!("Failed to create {parent:?}"))?; }
std::fs::write(&desktop, render_desktop(&exe))
.with_context(|| format!("Failed to write desktop file {desktop:?}"))?;
println!("\x1b[1;32m✓\x1b[0m App menu entry written: {}", desktop.display());
Ok(())
} }
let contents = render_unit(&exe); /// Remove the .desktop file.
std::fs::write(&path, &contents) pub fn uninstall_desktop() -> Result<()> {
.with_context(|| format!("Failed to write unit file {path:?}"))?; let desktop = desktop_path()?;
println!("Wrote unit: {}", path.display()); if desktop.exists() {
println!("ExecStart: {}", exe.display()); std::fs::remove_file(&desktop)
.with_context(|| format!("Failed to remove {desktop:?}"))?;
println!("Removed {}", desktop.display());
} else {
println!("No desktop file at {}", desktop.display());
}
Ok(())
}
/// Write the unit + desktop file, reload systemd, and enable+start the service.
pub fn install() -> Result<()> {
let exe = std::env::current_exe().context("Cannot determine path to own executable")?;
// systemd unit
let unit = unit_path()?;
if let Some(p) = unit.parent() {
std::fs::create_dir_all(p).with_context(|| format!("Failed to create {p:?}"))?;
}
std::fs::write(&unit, render_unit(&exe))
.with_context(|| format!("Failed to write unit file {unit:?}"))?;
println!("Wrote unit: {}", unit.display());
// .desktop file
install_desktop()?;
println!("Exec: {} gui", exe.display());
println!(); println!();
systemctl(&["daemon-reload"])?; systemctl(&["daemon-reload"])?;
@@ -62,25 +116,26 @@ pub fn install() -> Result<()> {
println!(); println!();
println!("\x1b[1;32m✓\x1b[0m Service installed and started."); println!("\x1b[1;32m✓\x1b[0m Service installed and started.");
println!(" umutray autostarts with your session and is in the app menu.");
println!(" Status: systemctl --user status {UNIT_NAME}"); println!(" Status: systemctl --user status {UNIT_NAME}");
println!(" Logs: journalctl --user -u {UNIT_NAME} -f"); println!(" Logs: journalctl --user -u {UNIT_NAME} -f");
Ok(()) Ok(())
} }
/// Stop, disable, and remove the unit file. /// Stop, disable, and remove the unit + desktop files.
pub fn uninstall() -> Result<()> { pub fn uninstall() -> Result<()> {
let path = unit_path()?;
// Ignore failures: the unit may already be stopped or unknown to systemd.
let _ = systemctl(&["disable", "--now", UNIT_NAME]); let _ = systemctl(&["disable", "--now", UNIT_NAME]);
if path.exists() { let unit = unit_path()?;
std::fs::remove_file(&path).with_context(|| format!("Failed to remove {path:?}"))?; if unit.exists() {
println!("Removed {}", path.display()); std::fs::remove_file(&unit).with_context(|| format!("Failed to remove {unit:?}"))?;
println!("Removed {}", unit.display());
} else { } else {
println!("No unit file at {}", path.display()); println!("No unit file at {}", unit.display());
} }
uninstall_desktop()?;
let _ = systemctl(&["daemon-reload"]); let _ = systemctl(&["daemon-reload"]);
println!("\x1b[1;32m✓\x1b[0m Service removed."); println!("\x1b[1;32m✓\x1b[0m Service removed.");
Ok(()) Ok(())
+256 -27
View File
@@ -1,8 +1,8 @@
use crate::config::{Config, Launcher}; use crate::{config::{self, Config, Launcher}, util::async_blocking};
use rfd;
use anyhow::Result; use anyhow::Result;
use iced::futures::channel::oneshot;
use iced::widget::{ use iced::widget::{
button, column, container, progress_bar, row, scrollable, text, text_input, Column, button, column, container, pick_list, progress_bar, row, scrollable, text, text_input, Column,
}; };
use iced::{Element, Length, Subscription, Task, Theme}; use iced::{Element, Length, Subscription, Task, Theme};
use std::ffi::OsString; use std::ffi::OsString;
@@ -14,6 +14,14 @@ use std::time::Duration;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Message { pub enum Message {
// Picking stage
TemplateSelected(String),
PrefixChanged(String),
BrowsePrefix,
BrowsePrefixDone(Option<String>),
ConfirmLauncher,
// Install stage
Back,
SourceChanged(String), SourceChanged(String),
PreparePressed, PreparePressed,
PrepareDone(Result<PathBuf, String>), PrepareDone(Result<PathBuf, String>),
@@ -24,6 +32,7 @@ pub enum Message {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum Stage { enum Stage {
Picking,
Idle, Idle,
Busy, Busy,
Ready, Ready,
@@ -39,26 +48,71 @@ struct DownloadProgress {
struct State { struct State {
config: Config, config: Config,
launcher: Launcher, launcher: Option<Launcher>,
// Picking stage fields
template_options: Vec<String>,
selected_template: Option<String>,
prefix_input: String,
// Install stage fields
source: String, source: String,
installer: Option<PathBuf>, installer: Option<PathBuf>,
/// Tracks a file we downloaded to a temp path so we can delete it on exit or failure.
/// Not set for local files the user pointed us at — those are never deleted.
downloaded_temp: Option<PathBuf>,
stage: Stage, stage: Stage,
status: String, status: String,
download: Arc<Mutex<DownloadProgress>>, download: Arc<Mutex<DownloadProgress>>,
log: Arc<Mutex<Vec<String>>>, log: Arc<Mutex<Vec<String>>>,
} }
impl Drop for State {
fn drop(&mut self) {
if let Some(path) = self.downloaded_temp.take() {
let _ = std::fs::remove_file(path);
}
}
}
impl State { impl State {
fn new(config: Config, launcher: Launcher) -> Self { fn new_picking(config: Config) -> Self {
let template_options = config::presets()
.iter()
.map(|l| l.display.clone())
.collect();
Self {
config,
launcher: None,
template_options,
selected_template: None,
prefix_input: String::new(),
source: String::new(),
installer: None,
downloaded_temp: None,
stage: Stage::Picking,
status: String::new(),
download: Arc::new(Mutex::new(DownloadProgress::default())),
log: Arc::new(Mutex::new(Vec::new())),
}
}
fn new_install(config: Config, launcher: Launcher) -> Self {
let status = format!( let status = format!(
"Paste an installer URL or a local .exe path. It will install into {}.", "Paste an installer URL or a local .exe path. It will install into {}.",
launcher.prefix_dir.display() launcher.prefix_dir.display()
); );
let template_options = config::presets()
.iter()
.map(|l| l.display.clone())
.collect();
Self { Self {
config, config,
launcher, launcher: Some(launcher),
template_options,
selected_template: None,
prefix_input: String::new(),
source: String::new(), source: String::new(),
installer: None, installer: None,
downloaded_temp: None,
stage: Stage::Idle, stage: Stage::Idle,
status, status,
download: Arc::new(Mutex::new(DownloadProgress::default())), download: Arc::new(Mutex::new(DownloadProgress::default())),
@@ -69,6 +123,78 @@ impl State {
fn update(state: &mut State, message: Message) -> Task<Message> { fn update(state: &mut State, message: Message) -> Task<Message> {
match message { match message {
// ── Picking stage ──────────────────────────────────────────────────
Message::TemplateSelected(display) => {
// Pre-fill prefix dir from the matching preset
let prefix = config::presets()
.into_iter()
.find(|p| p.display == display)
.map(|p| p.prefix_dir.to_string_lossy().into_owned())
.unwrap_or_default();
state.prefix_input = prefix;
state.selected_template = Some(display);
state.status = String::new();
Task::none()
}
Message::PrefixChanged(s) => {
state.prefix_input = s;
Task::none()
}
Message::BrowsePrefix => Task::perform(
async {
rfd::AsyncFileDialog::new()
.set_title("Choose install location (Wine prefix)")
.pick_folder()
.await
.map(|h| h.path().to_string_lossy().into_owned())
},
Message::BrowsePrefixDone,
),
Message::BrowsePrefixDone(path) => {
if let Some(p) = path {
state.prefix_input = p;
}
Task::none()
}
Message::ConfirmLauncher => {
let Some(display) = state.selected_template.clone() else {
state.status = "Please select a launcher first.".into();
return Task::none();
};
let prefix = state.prefix_input.trim().to_string();
if prefix.is_empty() {
state.status = "Please enter an install location.".into();
return Task::none();
}
let Some(mut preset) =
config::presets().into_iter().find(|p| p.display == display)
else {
state.status = "Unknown launcher template.".into();
return Task::none();
};
preset.prefix_dir = PathBuf::from(&prefix);
state.status = format!(
"Paste an installer URL or a local .exe path. It will install into {}.",
preset.prefix_dir.display()
);
state.launcher = Some(preset);
state.stage = Stage::Idle;
Task::none()
}
// ── Install stage ──────────────────────────────────────────────────
Message::Back => {
if let Some(l) = &state.launcher {
state.prefix_input = l.prefix_dir.to_string_lossy().into_owned();
}
if let Some(tmp) = state.downloaded_temp.take() {
let _ = std::fs::remove_file(tmp);
}
state.installer = None;
state.stage = Stage::Picking;
Task::none()
}
Message::Tick => Task::none(), Message::Tick => Task::none(),
Message::SourceChanged(s) => { Message::SourceChanged(s) => {
state.source = s; state.source = s;
@@ -96,14 +222,20 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
if let Ok(mut p) = state.download.lock() { if let Ok(mut p) = state.download.lock() {
*p = DownloadProgress::default(); *p = DownloadProgress::default();
} }
let name = state.launcher.name.clone(); let name = state
.launcher
.as_ref()
.expect("launcher set before install")
.name
.clone();
let progress = state.download.clone(); let progress = state.download.clone();
Task::perform( Task::perform(
blocking(move || download_blocking(&src, &name, progress)), async_blocking(move || download_blocking(&src, &name, progress)),
Message::PrepareDone, Message::PrepareDone,
) )
} }
Message::PrepareDone(Ok(path)) => { Message::PrepareDone(Ok(path)) => {
state.downloaded_temp = Some(path.clone());
state.installer = Some(path.clone()); state.installer = Some(path.clone());
state.stage = Stage::Ready; state.stage = Stage::Ready;
state.status = format!("Downloaded to {}", path.display()); state.status = format!("Downloaded to {}", path.display());
@@ -124,22 +256,40 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
v.clear(); v.clear();
} }
let config = state.config.clone(); let config = state.config.clone();
let launcher = state.launcher.clone(); let launcher = state
.launcher
.clone()
.expect("launcher set before install");
let log = state.log.clone(); let log = state.log.clone();
Task::perform( Task::perform(
blocking(move || run_installer(&config, &launcher, &installer, log)), async_blocking(move || run_installer(&config, &launcher, &installer, log)),
Message::InstallDone, Message::InstallDone,
) )
} }
Message::InstallDone(res) => { Message::InstallDone(res) => {
state.stage = Stage::Finished; state.stage = Stage::Finished;
let exe = state.launcher.full_exe_path(); let launcher = state
.launcher
.as_ref()
.expect("launcher set before install");
let exe = launcher.full_exe_path();
// Save to config only on successful install
if matches!(&res, Ok(_)) && exe.exists() {
if state.config.find(&launcher.name).is_none() {
state.config.launchers.push(launcher.clone());
let _ = state.config.save();
}
}
// Always clean up downloaded temp file
if let Some(tmp) = state.downloaded_temp.take() {
let _ = std::fs::remove_file(tmp);
}
state.status = match res { state.status = match res {
Ok(code) if exe.exists() => format!( Ok(code) if exe.exists() => format!(
"✓ Installer finished (umu exit {code}); {} is present.\n\ "✓ Installer finished (umu exit {code}); {} is present.\n\
You can now run: umutray launch {}", You can now run: umutray launch {}",
exe.display(), exe.display(),
state.launcher.name, launcher.name,
), ),
Ok(code) => format!( Ok(code) => format!(
"umu-run exited {code} but the expected exe is not at {}.\n\ "umu-run exited {code} but the expected exe is not at {}.\n\
@@ -163,11 +313,88 @@ fn subscription(state: &State) -> Subscription<Message> {
} }
fn view(state: &State) -> Element<'_, Message> { fn view(state: &State) -> Element<'_, Message> {
let header = text(format!("Setup: {}", state.launcher.display)).size(24); if matches!(state.stage, Stage::Picking) {
let prefix = text(format!("Prefix: {}", state.launcher.prefix_dir.display())).size(13); return view_picking(state);
}
view_install(state)
}
fn view_picking(state: &State) -> Element<'_, Message> {
let header = text("Add a Launcher").size(24);
let sub = text("Choose a launcher and where to install it.").size(13);
let picker = pick_list(
state.template_options.as_slice(),
state.selected_template.clone(),
Message::TemplateSelected,
)
.placeholder("Select a launcher…")
.width(Length::Fill);
let prefix_input = text_input("/home/user/Games/battlenet", &state.prefix_input)
.on_input(Message::PrefixChanged)
.padding(8)
.width(Length::Fill);
let browse_btn = button(text("Browse…").size(13))
.on_press(Message::BrowsePrefix)
.style(button::secondary);
let can_confirm =
state.selected_template.is_some() && !state.prefix_input.trim().is_empty();
let confirm_btn = button(text("Next →"))
.on_press_maybe(can_confirm.then_some(Message::ConfirmLauncher))
.style(button::primary);
let mut body = column![
header,
sub,
text("Launcher:").size(13),
picker,
text("Install location (Wine prefix):").size(13),
row![prefix_input, browse_btn].spacing(8).align_y(iced::Alignment::Center),
confirm_btn,
]
.spacing(10)
.padding(20);
if !state.status.is_empty() {
body = body.push(text(state.status.clone()).size(13));
}
container(body).into()
}
fn view_install(state: &State) -> Element<'_, Message> {
let launcher = state
.launcher
.as_ref()
.expect("launcher must be set in install stage");
let can_go_back = state.selected_template.is_some()
&& matches!(state.stage, Stage::Idle | Stage::Ready | Stage::Finished);
let back_btn: Element<Message> = if state.selected_template.is_some() {
button(text("← Back").size(13))
.on_press_maybe(can_go_back.then_some(Message::Back))
.style(button::secondary)
.into()
} else {
text("").into()
};
let header = row![
text(format!("Setup: {}", launcher.display)).size(24),
iced::widget::horizontal_space(),
back_btn,
]
.align_y(iced::Alignment::Center);
let prefix = text(format!("Prefix: {}", launcher.prefix_dir.display())).size(13);
let expected = text(format!( let expected = text(format!(
"Expected: {}", "Expected: {}",
state.launcher.full_exe_path().display() launcher.full_exe_path().display()
)) ))
.size(13); .size(13);
@@ -243,26 +470,28 @@ pub fn run(config: &Config, launcher: &Launcher) -> Result<()> {
let config = config.clone(); let config = config.clone();
let launcher = launcher.clone(); let launcher = launcher.clone();
let title = format!("umutray setup — {}", launcher.display); let title = format!("umutray setup — {}", launcher.display);
iced::application(move |_: &State| title.clone(), update, view) iced::application(move |_: &State| title.clone(), update, view)
.subscription(subscription) .subscription(subscription)
.theme(|_| Theme::Dark) .theme(|_| Theme::Dark)
.run_with(move || (State::new(config.clone(), launcher.clone()), Task::none())) .run_with(move || {
(
State::new_install(config.clone(), launcher.clone()),
Task::none(),
)
})
.map_err(|e| anyhow::anyhow!("iced: {e}")) .map_err(|e| anyhow::anyhow!("iced: {e}"))
} }
async fn blocking<T, F>(f: F) -> T pub fn run_new(config: &Config) -> Result<()> {
where let config = config.clone();
T: Send + 'static, iced::application(|_: &State| "umutray — Add Launcher".to_string(), update, view)
F: FnOnce() -> T + Send + 'static, .subscription(subscription)
{ .theme(|_| Theme::Dark)
let (tx, rx) = oneshot::channel(); .run_with(move || (State::new_picking(config.clone()), Task::none()))
std::thread::spawn(move || { .map_err(|e| anyhow::anyhow!("iced: {e}"))
let _ = tx.send(f());
});
rx.await.expect("setup helper thread panicked")
} }
fn download_blocking( fn download_blocking(
url: &str, url: &str,
name: &str, name: &str,
+36 -1
View File
@@ -16,6 +16,20 @@ fn spawn_setup(name: &str) {
} }
} }
fn spawn_gui() {
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
if let Err(e) = std::process::Command::new(exe).arg("gui").spawn() {
eprintln!("umutray: failed to launch dashboard: {e}");
}
}
fn spawn_setup_picker() {
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
if let Err(e) = std::process::Command::new(exe).arg("setup").spawn() {
eprintln!("umutray: failed to launch setup picker: {e}");
}
}
enum GameFlag { enum GameFlag {
GameMode, GameMode,
MangoHud, MangoHud,
@@ -89,7 +103,16 @@ impl ksni::Tray for UmuTray {
fn menu(&self) -> Vec<ksni::MenuItem<Self>> { fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
use ksni::menu::*; use ksni::menu::*;
let mut items: Vec<ksni::MenuItem<Self>> = vec![]; let mut items: Vec<ksni::MenuItem<Self>> = vec![
StandardItem {
label: "Open Dashboard".into(),
icon_name: "applications-games".into(),
activate: Box::new(|_: &mut Self| spawn_gui()),
..Default::default()
}
.into(),
ksni::MenuItem::Separator,
];
for l in &self.config.launchers { for l in &self.config.launchers {
let installed = l.full_exe_path().exists(); let installed = l.full_exe_path().exists();
@@ -217,6 +240,18 @@ impl ksni::Tray for UmuTray {
} }
} }
if self.config.launchers.is_empty() {
items.push(
StandardItem {
label: "Add Launcher…".into(),
icon_name: "list-add".into(),
activate: Box::new(|_: &mut Self| spawn_setup_picker()),
..Default::default()
}
.into(),
);
}
items.push(ksni::MenuItem::Separator); items.push(ksni::MenuItem::Separator);
items.push( items.push(
+16
View File
@@ -0,0 +1,16 @@
use iced::futures::channel::oneshot;
/// Run a blocking closure on a thread pool thread and await its result.
/// Used to offload blocking work (HTTP, disk, process spawning) without
/// stalling the iced event loop.
pub async fn async_blocking<T, F>(f: F) -> T
where
T: Send + 'static,
F: FnOnce() -> T + Send + 'static,
{
let (tx, rx) = oneshot::channel();
std::thread::spawn(move || {
let _ = tx.send(f());
});
rx.await.expect("blocking task panicked")
}
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Name=umutray
Comment=Wine launcher manager for Windows game launchers
Exec=umutray gui
Icon=applications-games
Type=Application
Categories=Game;
Keywords=wine;proton;gaming;launcher;
StartupNotify=true