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 {
proton_compat_dir: default_compat_dir(),
proton_version: default_proton_version(),
launchers: presets(),
launchers: vec![],
}
}
}
@@ -227,11 +227,7 @@ impl Config {
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read config from {path:?}"))?;
match toml::from_str::<Self>(&content) {
Ok(mut c) => {
if c.launchers.is_empty() {
c.launchers = presets();
c.save().context("Failed to write presets")?;
}
Ok(c) => {
Ok(c)
}
Err(e) => {
+32
View File
@@ -3,6 +3,38 @@ use anyhow::Result;
use std::collections::HashMap;
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;
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::process::{Command, Stdio};
struct Check {
label: String,
pass: bool,
detail: String,
#[derive(Debug, Clone)]
pub struct CheckResult {
pub label: String,
pub pass: bool,
pub detail: String,
}
impl Check {
impl CheckResult {
fn pass(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
label: label.into(),
pass: true,
detail: detail.into(),
}
Self { label: label.into(), pass: true, detail: detail.into() }
}
fn fail(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
label: label.into(),
pass: false,
detail: detail.into(),
}
Self { label: label.into(), pass: false, detail: detail.into() }
}
}
pub fn run(config: &Config, name: Option<&str>) -> Result<()> {
let mut checks: Vec<Check> = vec![
pub fn run_checks(config: &Config, name: Option<&str>) -> Result<Vec<CheckResult>> {
let mut checks = vec![
global_umu_check(),
global_vulkan_check(),
global_display_check(),
compat_dir_check(config),
wineserver_check(config),
];
let launchers: Vec<&Launcher> = if let Some(n) = name {
let l = config
.find(n)
@@ -47,7 +39,11 @@ pub fn run(config: &Config, name: Option<&str>) -> Result<()> {
for l in launchers {
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;
println!();
for c in &checks {
@@ -71,14 +67,14 @@ pub fn run(config: &Config, name: Option<&str>) -> Result<()> {
Ok(())
}
fn global_umu_check() -> Check {
fn global_umu_check() -> CheckResult {
match which("umu-run") {
Some(p) => Check::pass("umu-run", format!("found at {p}")),
None => Check::fail("umu-run", "not found — install umu-launcher"),
Some(p) => CheckResult::pass("umu-run", format!("found at {p}")),
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")
.arg("--summary")
.stdout(Stdio::null())
@@ -87,33 +83,33 @@ fn global_vulkan_check() -> Check {
.map(|s| s.success())
.unwrap_or(false);
if ok {
Check::pass("vulkan", "vulkaninfo OK")
CheckResult::pass("vulkan", "vulkaninfo OK")
} else {
Check::fail(
CheckResult::fail(
"vulkan",
"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 wayland = std::env::var("WAYLAND_DISPLAY").ok();
match (display, wayland) {
(Some(d), Some(_)) => Check::pass("display", format!("XWayland (DISPLAY={d})")),
(Some(d), None) => Check::pass("display", format!("X11 (DISPLAY={d})")),
(None, Some(_)) => Check::fail(
(Some(d), Some(_)) => CheckResult::pass("display", format!("XWayland (DISPLAY={d})")),
(Some(d), None) => CheckResult::pass("display", format!("X11 (DISPLAY={d})")),
(None, Some(_)) => CheckResult::fail(
"display",
"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);
if config.proton_version == "GE-Proton" {
Check::pass(
CheckResult::pass(
"proton",
format!(
"tracking latest; {n} version(s) in {}",
@@ -123,9 +119,9 @@ fn compat_dir_check(config: &Config) -> Check {
} else {
let path = config.proton_compat_dir.join(&config.proton_version);
if path.exists() {
Check::pass("proton", format!("{} installed", config.proton_version))
CheckResult::pass("proton", format!("{} installed", config.proton_version))
} else {
Check::fail(
CheckResult::fail(
"proton",
format!(
"{} 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();
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);
if any_running {
Check::pass(
CheckResult::pass(
"wine procs",
format!("{count} wineserver process(es); launcher active"),
)
} else {
Check::fail(
CheckResult::fail(
"wine procs",
format!("{count} stale wineserver process(es) — try: umutray kill"),
)
@@ -165,12 +161,12 @@ fn wineserver_count() -> usize {
.unwrap_or(0)
}
fn launcher_checks(l: &Launcher) -> Vec<Check> {
fn launcher_checks(l: &Launcher) -> Vec<CheckResult> {
let mut out = Vec::new();
let tag = format!("[{}]", l.name);
if !l.prefix_dir.exists() {
out.push(Check::fail(
out.push(CheckResult::fail(
format!("{tag} prefix"),
format!(
"{} missing — run: umutray setup {}",
@@ -180,34 +176,34 @@ fn launcher_checks(l: &Launcher) -> Vec<Check> {
));
return out;
}
out.push(Check::pass(
out.push(CheckResult::pass(
format!("{tag} prefix"),
l.prefix_dir.display().to_string(),
));
let exe = l.full_exe_path();
if exe.exists() {
out.push(Check::pass(format!("{tag} exe"), "installed"));
out.push(CheckResult::pass(format!("{tag} exe"), "installed"));
} else {
out.push(Check::fail(
out.push(CheckResult::fail(
format!("{tag} exe"),
format!("missing — run: umutray setup {}", l.name),
));
}
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 {
out.push(Check::fail(
out.push(CheckResult::fail(
format!("{tag} owner"),
"not owned by current user",
));
}
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 {
out.push(Check::pass(format!("{tag} process"), "not running"));
out.push(CheckResult::pass(format!("{tag} process"), "not running"));
}
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 detect;
mod diagnose;
mod gui;
mod launcher;
mod proton;
mod service;
mod setup;
mod tray;
mod util;
use anyhow::Result;
use clap::{Parser, Subcommand};
@@ -62,12 +64,15 @@ enum Commands {
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 {
/// Launcher name
name: String,
/// Launcher name (e.g. battlenet). Omit to open the launcher picker.
name: Option<String>,
},
/// Open the graphical dashboard (default when launched from app menu)
Gui,
/// Scan common Wine prefix locations for installed launchers
Detect {
/// Additional directory to scan (repeatable)
@@ -214,12 +219,16 @@ enum ConfigAction {
#[derive(Subcommand)]
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,
/// Stop, disable, and remove the unit file
/// Stop, disable, and remove the unit file (includes app menu entry)
Uninstall,
/// Show `systemctl --user status` for the service
Status,
/// Install only the app menu entry — no systemd service required
InstallDesktop,
/// Remove the app menu entry
UninstallDesktop,
}
fn main() -> Result<()> {
@@ -305,12 +314,19 @@ fn main() -> Result<()> {
}
}
Commands::Setup { name } => {
let l = config.find(&name).ok_or_else(|| {
anyhow::anyhow!("unknown launcher '{name}' — try `umutray launchers`")
Commands::Setup { name } => match name {
None => setup::run_new(&config)?,
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)?;
}
},
Commands::Gui => gui::run(&config)?,
Commands::Detect { dir, apply } => {
detect::run(&config, &dir, apply)?;
@@ -402,6 +418,8 @@ fn main() -> Result<()> {
ServiceAction::Install => service::install()?,
ServiceAction::Uninstall => service::uninstall()?,
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;
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> {
let home = std::env::var("HOME").context("$HOME is not set")?;
Ok(PathBuf::from(home)
.join(".config/systemd/user")
.join(UNIT_NAME))
Ok(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 {
format!(
"[Unit]\n\
Description=Battle.net tray manager\n\
Description=umutray Wine launcher manager\n\
After=graphical-session.target\n\
PartOf=graphical-session.target\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<()> {
let status = Command::new("systemctl")
.arg("--user")
@@ -41,20 +66,49 @@ fn systemctl(args: &[&str]) -> Result<()> {
Ok(())
}
/// Write the unit, reload systemd, and enable+start the service.
pub fn install() -> Result<()> {
/// Install only the .desktop file so umutray appears in the app menu.
/// 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 path = unit_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("Failed to create {parent:?}"))?;
let desktop = desktop_path()?;
if let Some(p) = desktop.parent() {
std::fs::create_dir_all(p).with_context(|| format!("Failed to create {p:?}"))?;
}
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);
std::fs::write(&path, &contents)
.with_context(|| format!("Failed to write unit file {path:?}"))?;
println!("Wrote unit: {}", path.display());
println!("ExecStart: {}", exe.display());
/// Remove the .desktop file.
pub fn uninstall_desktop() -> Result<()> {
let desktop = desktop_path()?;
if desktop.exists() {
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!();
systemctl(&["daemon-reload"])?;
@@ -62,25 +116,26 @@ pub fn install() -> Result<()> {
println!();
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!(" Logs: journalctl --user -u {UNIT_NAME} -f");
Ok(())
}
/// Stop, disable, and remove the unit file.
/// Stop, disable, and remove the unit + desktop files.
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]);
if path.exists() {
std::fs::remove_file(&path).with_context(|| format!("Failed to remove {path:?}"))?;
println!("Removed {}", path.display());
let unit = unit_path()?;
if unit.exists() {
std::fs::remove_file(&unit).with_context(|| format!("Failed to remove {unit:?}"))?;
println!("Removed {}", unit.display());
} else {
println!("No unit file at {}", path.display());
println!("No unit file at {}", unit.display());
}
uninstall_desktop()?;
let _ = systemctl(&["daemon-reload"]);
println!("\x1b[1;32m✓\x1b[0m Service removed.");
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 iced::futures::channel::oneshot;
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 std::ffi::OsString;
@@ -14,6 +14,14 @@ use std::time::Duration;
#[derive(Debug, Clone)]
pub enum Message {
// Picking stage
TemplateSelected(String),
PrefixChanged(String),
BrowsePrefix,
BrowsePrefixDone(Option<String>),
ConfirmLauncher,
// Install stage
Back,
SourceChanged(String),
PreparePressed,
PrepareDone(Result<PathBuf, String>),
@@ -24,6 +32,7 @@ pub enum Message {
#[derive(Debug, Clone)]
enum Stage {
Picking,
Idle,
Busy,
Ready,
@@ -39,26 +48,71 @@ struct DownloadProgress {
struct State {
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,
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,
status: String,
download: Arc<Mutex<DownloadProgress>>,
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 {
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!(
"Paste an installer URL or a local .exe path. It will install into {}.",
launcher.prefix_dir.display()
);
let template_options = config::presets()
.iter()
.map(|l| l.display.clone())
.collect();
Self {
config,
launcher,
launcher: Some(launcher),
template_options,
selected_template: None,
prefix_input: String::new(),
source: String::new(),
installer: None,
downloaded_temp: None,
stage: Stage::Idle,
status,
download: Arc::new(Mutex::new(DownloadProgress::default())),
@@ -69,6 +123,78 @@ impl State {
fn update(state: &mut State, message: Message) -> Task<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::SourceChanged(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() {
*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();
Task::perform(
blocking(move || download_blocking(&src, &name, progress)),
async_blocking(move || download_blocking(&src, &name, progress)),
Message::PrepareDone,
)
}
Message::PrepareDone(Ok(path)) => {
state.downloaded_temp = Some(path.clone());
state.installer = Some(path.clone());
state.stage = Stage::Ready;
state.status = format!("Downloaded to {}", path.display());
@@ -124,22 +256,40 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
v.clear();
}
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();
Task::perform(
blocking(move || run_installer(&config, &launcher, &installer, log)),
async_blocking(move || run_installer(&config, &launcher, &installer, log)),
Message::InstallDone,
)
}
Message::InstallDone(res) => {
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 {
Ok(code) if exe.exists() => format!(
"✓ Installer finished (umu exit {code}); {} is present.\n\
You can now run: umutray launch {}",
exe.display(),
state.launcher.name,
launcher.name,
),
Ok(code) => format!(
"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> {
let header = text(format!("Setup: {}", state.launcher.display)).size(24);
let prefix = text(format!("Prefix: {}", state.launcher.prefix_dir.display())).size(13);
if matches!(state.stage, Stage::Picking) {
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!(
"Expected: {}",
state.launcher.full_exe_path().display()
launcher.full_exe_path().display()
))
.size(13);
@@ -243,26 +470,28 @@ pub fn run(config: &Config, launcher: &Launcher) -> Result<()> {
let config = config.clone();
let launcher = launcher.clone();
let title = format!("umutray setup — {}", launcher.display);
iced::application(move |_: &State| title.clone(), update, view)
.subscription(subscription)
.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}"))
}
async fn 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("setup helper thread panicked")
pub fn run_new(config: &Config) -> Result<()> {
let config = config.clone();
iced::application(|_: &State| "umutray — Add Launcher".to_string(), update, view)
.subscription(subscription)
.theme(|_| Theme::Dark)
.run_with(move || (State::new_picking(config.clone()), Task::none()))
.map_err(|e| anyhow::anyhow!("iced: {e}"))
}
fn download_blocking(
url: &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 {
GameMode,
MangoHud,
@@ -89,7 +103,16 @@ impl ksni::Tray for UmuTray {
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
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 {
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(
+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