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
+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}"))
}
+29 -11
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`")
})?;
setup::run(&config, l)?;
}
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()?,
},
}
+77 -22
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.
/// 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 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(())
}
/// 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")?;
let path = unit_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("Failed to create {parent:?}"))?;
// 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());
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());
// .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")
}