Add setup tray entry, wizard progress/log, config add/remove, stale-wine check

- tray: uninstalled launchers now show a "Setup…" entry that spawns
  the setup wizard as a child process.
- setup.rs: download shows a progress bar (bytes / total), and
  umu-run stdout+stderr stream into a scrollable log pane. A 250 ms
  tick subscription pulls updates from the shared state.
- config add-launcher / remove-launcher CLI, with sensible defaults
  for prefix_dir, gameid, and process_pattern derived from name/exe.
- diagnose: flag stale wineserver processes when no launcher is
  running, suggesting `umutray kill`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-17 12:46:47 -07:00
parent 14eccf4ef0
commit 22fa1efabf
8 changed files with 339 additions and 20 deletions
Generated
+1
View File
@@ -1827,6 +1827,7 @@ dependencies = [
"iced_core",
"log",
"rustc-hash 2.1.2",
"tokio",
"wasm-bindgen-futures",
"wasm-timer",
]
+1 -1
View File
@@ -37,4 +37,4 @@ ksni = "0.2"
reqwest = { version = "0.12", features = ["blocking", "json"] }
# GUI for the setup wizard
iced = "0.13"
iced = { version = "0.13", features = ["tokio"] }
+6 -3
View File
@@ -26,9 +26,10 @@ Launch / Kill entries. Users can add or remove launchers in `config edit`.
prefix / exe / ownership / running state.
- `service` — installs a `systemd --user` unit so the tray autostarts with
the graphical session.
- `setup` — graphical wizard (iced) that downloads an installer URL or
accepts a local `.exe`, then runs it via `umu-run` in the launcher's
Wine prefix.
- `setup` — graphical wizard (iced) that downloads an installer URL
(with progress bar) or accepts a local `.exe`, then runs it via
`umu-run` in the launcher's Wine prefix with a live log pane.
Uninstalled launchers expose a **Setup…** entry directly in the tray.
## Install
@@ -65,6 +66,8 @@ umutray service install
| `umutray config show` / `path` | Print current config or its file path |
| `umutray config edit` | Open config in `$EDITOR` |
| `umutray config set …` | Update globals (`--proton-version`, `--compat-dir`) |
| `umutray config add-launcher …` | Append a new launcher (needs `--exe-path`) |
| `umutray config remove-launcher` | Drop a launcher (prefix on disk is left untouched) |
| `umutray service install` | Write + enable a `systemd --user` unit |
| `umutray service uninstall` | Stop, disable, and remove the unit |
| `umutray service status` | `systemctl --user status umutray.service` |
+69
View File
@@ -61,6 +61,21 @@ fn default_proton_version() -> String {
"GE-Proton".into()
}
fn regex_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 4);
for c in s.chars() {
if matches!(
c,
'.' | '*' | '?' | '+' | '(' | ')' | '[' | ']'
| '{' | '}' | '|' | '\\' | '^' | '$'
) {
out.push('\\');
}
out.push(c);
}
out
}
/// The six launchers umutray ships out of the box. `exe_path`, `gameid`,
/// and `process_pattern` are best-effort defaults for typical installs —
/// users can adjust per-launcher via `umutray config edit`.
@@ -231,6 +246,60 @@ impl Config {
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn add_launcher(
&mut self,
name: String,
display: Option<String>,
exe_path: PathBuf,
prefix_dir: Option<PathBuf>,
gameid: Option<String>,
process_pattern: Option<String>,
installer_url: Option<String>,
) -> Result<()> {
if self.launchers.iter().any(|l| l.name == name) {
anyhow::bail!("launcher '{name}' already exists");
}
let display = display.unwrap_or_else(|| name.clone());
let prefix_dir = prefix_dir
.unwrap_or_else(|| home_dir().join("Games").join(&name));
let gameid = gameid.unwrap_or_else(|| format!("umu-{name}"));
let process_pattern = process_pattern.unwrap_or_else(|| {
exe_path
.file_name()
.and_then(|s| s.to_str())
.map(regex_escape)
.unwrap_or_else(|| name.clone())
});
self.launchers.push(Launcher {
name: name.clone(),
display,
prefix_dir,
exe_path,
gameid,
process_pattern,
installer_url,
proton_version: None,
});
self.save()?;
println!("\x1b[1;32m✓\x1b[0m Added launcher '{name}'.");
Ok(())
}
pub fn remove_launcher(&mut self, name: &str) -> Result<()> {
let before = self.launchers.len();
self.launchers.retain(|l| l.name != name);
if self.launchers.len() == before {
anyhow::bail!("no launcher named '{name}'");
}
self.save()?;
println!(
"\x1b[1;32m✓\x1b[0m Removed '{name}'. \
The Wine prefix on disk was left untouched."
);
Ok(())
}
/// Update global fields non-interactively, then save.
/// Use `config edit` for per-launcher changes.
pub fn set_globals(
+35
View File
@@ -25,6 +25,7 @@ pub fn run(config: &Config, name: Option<&str>) -> Result<()> {
global_vulkan_check(),
global_display_check(),
compat_dir_check(config),
wineserver_check(config),
];
let launchers: Vec<&Launcher> = if let Some(n) = name {
@@ -124,6 +125,40 @@ fn compat_dir_check(config: &Config) -> Check {
}
}
fn wineserver_check(config: &Config) -> Check {
let count = wineserver_count();
if count == 0 {
return Check::pass("wine procs", "no wineserver running");
}
let any_running = config
.launchers
.iter()
.any(crate::launcher::is_running);
if any_running {
Check::pass(
"wine procs",
format!("{count} wineserver process(es); launcher active"),
)
} else {
Check::fail(
"wine procs",
format!(
"{count} stale wineserver process(es) — try: umutray kill"
),
)
}
}
fn wineserver_count() -> usize {
Command::new("pgrep")
.args(["-c", "-f", "wineserver"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.trim().parse::<usize>().ok())
.unwrap_or(0)
}
fn launcher_checks(l: &Launcher) -> Vec<Check> {
let mut out = Vec::new();
let tag = format!("[{}]", l.name);
+58
View File
@@ -98,6 +98,40 @@ enum ConfigAction {
#[arg(long, value_name = "PATH")]
compat_dir: Option<PathBuf>,
},
/// Add a new launcher to the config
AddLauncher {
/// Short CLI name (e.g. "heroic")
name: String,
/// Windows exe path relative to drive_c/ (e.g. "Program Files/Foo/foo.exe")
#[arg(long, value_name = "PATH")]
exe_path: PathBuf,
/// Display name for menus (defaults to NAME)
#[arg(long)]
display: Option<String>,
/// Wine prefix dir (defaults to ~/Games/NAME)
#[arg(long, value_name = "PATH")]
prefix_dir: Option<PathBuf>,
/// umu GAMEID (defaults to "umu-NAME")
#[arg(long)]
gameid: Option<String>,
/// pgrep -f regex (defaults to escaped exe basename)
#[arg(long)]
process_pattern: Option<String>,
/// Optional installer URL
#[arg(long)]
installer_url: Option<String>,
},
/// Remove a launcher from the config (leaves its prefix on disk)
RemoveLauncher {
/// Short CLI name
name: String,
},
}
#[derive(Subcommand)]
@@ -173,6 +207,30 @@ fn main() -> Result<()> {
let mut c = config;
c.set_globals(proton_version, compat_dir)?;
}
ConfigAction::AddLauncher {
name,
exe_path,
display,
prefix_dir,
gameid,
process_pattern,
installer_url,
} => {
let mut c = config;
c.add_launcher(
name,
display,
exe_path,
prefix_dir,
gameid,
process_pattern,
installer_url,
)?;
}
ConfigAction::RemoveLauncher { name } => {
let mut c = config;
c.remove_launcher(&name)?;
}
},
Commands::Service { action } => match action {
+151 -14
View File
@@ -1,10 +1,16 @@
use crate::config::{Config, Launcher};
use anyhow::Result;
use iced::futures::channel::oneshot;
use iced::widget::{button, column, container, row, text, text_input};
use iced::{Element, Task, Theme};
use iced::widget::{
button, column, container, progress_bar, row, scrollable, text, text_input, Column,
};
use iced::{Element, Length, Subscription, Task, Theme};
use std::ffi::OsString;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[derive(Debug, Clone)]
pub enum Message {
@@ -13,6 +19,7 @@ pub enum Message {
PrepareDone(Result<PathBuf, String>),
InstallPressed,
InstallDone(Result<i32, String>),
Tick,
}
#[derive(Debug, Clone)]
@@ -24,6 +31,12 @@ enum Stage {
Finished,
}
#[derive(Debug, Clone, Default)]
struct DownloadProgress {
bytes: u64,
total: Option<u64>,
}
struct State {
config: Config,
launcher: Launcher,
@@ -31,6 +44,8 @@ struct State {
installer: Option<PathBuf>,
stage: Stage,
status: String,
download: Arc<Mutex<DownloadProgress>>,
log: Arc<Mutex<Vec<String>>>,
}
impl State {
@@ -46,12 +61,15 @@ impl State {
installer: None,
stage: Stage::Idle,
status,
download: Arc::new(Mutex::new(DownloadProgress::default())),
log: Arc::new(Mutex::new(Vec::new())),
}
}
}
fn update(state: &mut State, message: Message) -> Task<Message> {
match message {
Message::Tick => Task::none(),
Message::SourceChanged(s) => {
state.source = s;
Task::none()
@@ -62,7 +80,6 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
state.status = "Enter an installer URL or local path first.".into();
return Task::none();
}
// Local file on disk?
let path = PathBuf::from(&src);
if path.is_file() {
state.installer = Some(path.clone());
@@ -76,9 +93,13 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
}
state.stage = Stage::Busy;
state.status = format!("Downloading {src}");
if let Ok(mut p) = state.download.lock() {
*p = DownloadProgress::default();
}
let name = state.launcher.name.clone();
let progress = state.download.clone();
Task::perform(
blocking(move || download_blocking(&src, &name)),
blocking(move || download_blocking(&src, &name, progress)),
Message::PrepareDone,
)
}
@@ -100,10 +121,14 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
state.stage = Stage::Installing;
state.status =
"Running installer via umu-run (this may take several minutes)…".into();
if let Ok(mut v) = state.log.lock() {
v.clear();
}
let config = state.config.clone();
let launcher = state.launcher.clone();
let log = state.log.clone();
Task::perform(
blocking(move || run_installer(&config, &launcher, &installer)),
blocking(move || run_installer(&config, &launcher, &installer, log)),
Message::InstallDone,
)
}
@@ -129,6 +154,15 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
}
}
fn subscription(state: &State) -> Subscription<Message> {
match state.stage {
Stage::Busy | Stage::Installing => {
iced::time::every(Duration::from_millis(250)).map(|_| Message::Tick)
}
_ => Subscription::none(),
}
}
fn view(state: &State) -> Element<'_, Message> {
let header = text(format!("Setup: {}", state.launcher.display)).size(24);
let prefix = text(format!(
@@ -157,13 +191,53 @@ fn view(state: &State) -> Element<'_, Message> {
let status = text(state.status.clone());
let progress_row: Element<Message> = if matches!(state.stage, Stage::Busy) {
let p = state
.download
.lock()
.map(|p| (p.bytes, p.total))
.unwrap_or((0, None));
let (bytes, total) = p;
let fraction = match total {
Some(t) if t > 0 => (bytes as f32) / (t as f32),
_ => 0.0,
};
let label = match total {
Some(t) => format!("{} / {}", fmt_bytes(bytes), fmt_bytes(t)),
None => format!("{} downloaded", fmt_bytes(bytes)),
};
column![progress_bar(0.0..=1.0, fraction), text(label).size(12)]
.spacing(4)
.into()
} else {
text("").into()
};
let log_pane: Element<Message> = if matches!(state.stage, Stage::Installing | Stage::Finished) {
let lines: Vec<String> = state.log.lock().map(|v| v.clone()).unwrap_or_default();
let tail: Vec<Element<Message>> = lines
.iter()
.rev()
.take(80)
.rev()
.map(|l| text(l.clone()).size(11).into())
.collect();
scrollable(Column::with_children(tail).spacing(2))
.height(Length::Fixed(220.0))
.into()
} else {
text("").into()
};
let body = column![
header,
prefix,
expected,
input,
row![prepare_btn, install_btn].spacing(12),
progress_row,
status,
log_pane,
]
.spacing(12)
.padding(20);
@@ -177,13 +251,12 @@ pub fn run(config: &Config, launcher: &Launcher) -> Result<()> {
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()))
.map_err(|e| anyhow::anyhow!("iced: {e}"))
}
/// Run a blocking closure on a helper thread and await the result
/// via a oneshot channel. Avoids pulling tokio in as a direct dep.
async fn blocking<T, F>(f: F) -> T
where
T: Send + 'static,
@@ -196,7 +269,11 @@ where
rx.await.expect("setup helper thread panicked")
}
fn download_blocking(url: &str, name: &str) -> Result<PathBuf, String> {
fn download_blocking(
url: &str,
name: &str,
progress: Arc<Mutex<DownloadProgress>>,
) -> Result<PathBuf, String> {
let client = reqwest::blocking::Client::builder()
.user_agent("umutray/0.1")
.build()
@@ -207,8 +284,17 @@ fn download_blocking(url: &str, name: &str) -> Result<PathBuf, String> {
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let total = resp.content_length();
if let Ok(mut p) = progress.lock() {
p.bytes = 0;
p.total = total;
}
let base = url.split('?').next().unwrap_or(url);
let candidate = base.rsplit('/').next().filter(|s| s.contains('.')).unwrap_or("");
let candidate = base
.rsplit('/')
.next()
.filter(|s| s.contains('.'))
.unwrap_or("");
let filename = if candidate.is_empty() {
format!("{name}-installer.exe")
} else {
@@ -216,7 +302,17 @@ fn download_blocking(url: &str, name: &str) -> Result<PathBuf, String> {
};
let tmp = std::env::temp_dir().join(filename);
let mut f = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
std::io::copy(&mut resp, &mut f).map_err(|e| e.to_string())?;
let mut buf = [0u8; 64 * 1024];
loop {
let n = resp.read(&mut buf).map_err(|e| e.to_string())?;
if n == 0 {
break;
}
f.write_all(&buf[..n]).map_err(|e| e.to_string())?;
if let Ok(mut p) = progress.lock() {
p.bytes += n as u64;
}
}
Ok(tmp)
}
@@ -224,23 +320,64 @@ fn run_installer(
config: &Config,
launcher: &Launcher,
installer: &Path,
log: Arc<Mutex<Vec<String>>>,
) -> Result<i32, String> {
std::fs::create_dir_all(&launcher.prefix_dir).map_err(|e| e.to_string())?;
let version = launcher
.proton_version
.as_deref()
.unwrap_or(&config.proton_version);
let proton_path: std::ffi::OsString = if version == "GE-Proton" {
let proton_path: OsString = if version == "GE-Proton" {
version.to_string().into()
} else {
config.proton_compat_dir.join(version).into_os_string()
};
let status = Command::new("umu-run")
let mut child = Command::new("umu-run")
.env("WINEPREFIX", &launcher.prefix_dir)
.env("GAMEID", &launcher.gameid)
.env("PROTONPATH", &proton_path)
.arg(installer)
.status()
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| e.to_string())?;
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let log_out = log.clone();
let log_err = log.clone();
let t_out = std::thread::spawn(move || stream_into(stdout, log_out));
let t_err = std::thread::spawn(move || stream_into(stderr, log_err));
let status = child.wait().map_err(|e| e.to_string())?;
let _ = t_out.join();
let _ = t_err.join();
Ok(status.code().unwrap_or(-1))
}
fn stream_into<R: Read>(r: R, log: Arc<Mutex<Vec<String>>>) {
for line in BufReader::new(r).lines().map_while(Result::ok) {
if let Ok(mut v) = log.lock() {
v.push(line);
if v.len() > 500 {
let drop = v.len() - 500;
v.drain(0..drop);
}
}
}
}
fn fmt_bytes(n: u64) -> String {
const KIB: u64 = 1024;
const MIB: u64 = 1024 * 1024;
const GIB: u64 = 1024 * 1024 * 1024;
if n >= GIB {
format!("{:.2} GiB", n as f64 / GIB as f64)
} else if n >= MIB {
format!("{:.1} MiB", n as f64 / MIB as f64)
} else if n >= KIB {
format!("{:.0} KiB", n as f64 / KIB as f64)
} else {
format!("{n} B")
}
}
+18 -2
View File
@@ -1,9 +1,21 @@
use crate::{config::Config, launcher};
use anyhow::Result;
use std::collections::HashMap;
use std::path::PathBuf;
use std::thread;
use std::time::Duration;
fn spawn_setup(name: &str) {
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
if let Err(e) = std::process::Command::new(exe)
.arg("setup")
.arg(name)
.spawn()
{
eprintln!("umutray: failed to launch setup for {name}: {e}");
}
}
pub struct UmuTray {
pub config: Config,
/// Per-launcher running state keyed by launcher.name
@@ -41,10 +53,14 @@ impl ksni::Tray for UmuTray {
let display = l.display.clone();
if !installed {
let setup_name = name.clone();
items.push(
StandardItem {
label: format!("{display} (not installed)"),
enabled: false,
label: format!("Setup {display}"),
icon_name: "document-new".into(),
activate: Box::new(move |_this: &mut Self| {
spawn_setup(&setup_name);
}),
..Default::default()
}
.into(),