Replace setup CLI stub with iced-based wizard

Lets the user paste an installer URL or local .exe path, downloads to
a temp file if needed, and runs the installer via umu-run with the
launcher's prefix, gameid, and proton path wired up. On completion,
checks for the expected exe and reports next steps.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-17 12:39:09 -07:00
parent 7e5ed3d447
commit 14eccf4ef0
4 changed files with 3794 additions and 79 deletions
Generated
+3552 -27
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -35,3 +35,6 @@ ksni = "0.2"
# HTTP for GE-Proton GitHub releases API
reqwest = { version = "0.12", features = ["blocking", "json"] }
# GUI for the setup wizard
iced = "0.13"
+4 -3
View File
@@ -26,8 +26,9 @@ 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`prints the manual setup steps for a launcher (a graphical
wizard via iced is planned).
- `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.
## Install
@@ -57,7 +58,7 @@ umutray service install
| `umutray launch <name>` | Launch a specific launcher (e.g. `umutray launch epic`) |
| `umutray kill [<name>]` | Kill one launcher, or all if no name is given |
| `umutray diagnose [<name>]` | Health checks (one launcher or all) |
| `umutray setup <name>` | Print setup steps for a launcher |
| `umutray setup <name>` | Open the graphical setup wizard for a launcher |
| `umutray update-proton --latest` | Install newest GE-Proton release |
| `umutray update-proton --list` | Show recent releases without installing |
| `umutray update-proton` | Interactive version picker |
+235 -49
View File
@@ -1,60 +1,246 @@
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 std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub enum Message {
SourceChanged(String),
PreparePressed,
PrepareDone(Result<PathBuf, String>),
InstallPressed,
InstallDone(Result<i32, String>),
}
#[derive(Debug, Clone)]
enum Stage {
Idle,
Busy,
Ready,
Installing,
Finished,
}
struct State {
config: Config,
launcher: Launcher,
source: String,
installer: Option<PathBuf>,
stage: Stage,
status: String,
}
impl State {
fn new(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()
);
Self {
config,
launcher,
source: String::new(),
installer: None,
stage: Stage::Idle,
status,
}
}
}
fn update(state: &mut State, message: Message) -> Task<Message> {
match message {
Message::SourceChanged(s) => {
state.source = s;
Task::none()
}
Message::PreparePressed => {
let src = state.source.trim().to_string();
if src.is_empty() {
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());
state.stage = Stage::Ready;
state.status = format!("Ready: {}", path.display());
return Task::none();
}
if !src.starts_with("http://") && !src.starts_with("https://") {
state.status = format!("Not a local file and not an http(s) URL: {src}");
return Task::none();
}
state.stage = Stage::Busy;
state.status = format!("Downloading {src}");
let name = state.launcher.name.clone();
Task::perform(
blocking(move || download_blocking(&src, &name)),
Message::PrepareDone,
)
}
Message::PrepareDone(Ok(path)) => {
state.installer = Some(path.clone());
state.stage = Stage::Ready;
state.status = format!("Downloaded to {}", path.display());
Task::none()
}
Message::PrepareDone(Err(e)) => {
state.stage = Stage::Idle;
state.status = format!("Download failed: {e}");
Task::none()
}
Message::InstallPressed => {
let Some(installer) = state.installer.clone() else {
return Task::none();
};
state.stage = Stage::Installing;
state.status =
"Running installer via umu-run (this may take several minutes)…".into();
let config = state.config.clone();
let launcher = state.launcher.clone();
Task::perform(
blocking(move || run_installer(&config, &launcher, &installer)),
Message::InstallDone,
)
}
Message::InstallDone(res) => {
state.stage = Stage::Finished;
let exe = state.launcher.full_exe_path();
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,
),
Ok(code) => format!(
"umu-run exited {code} but the expected exe is not at {}.\n\
Check the installer's destination path, or edit exe_path in config.",
exe.display(),
),
Err(e) => format!("Install failed: {e}"),
};
Task::none()
}
}
}
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);
let expected = text(format!(
"Expected: {}",
state.launcher.full_exe_path().display()
))
.size(13);
let input = text_input("https://… or /path/to/installer.exe", &state.source)
.on_input(Message::SourceChanged)
.padding(8);
let prepare_enabled =
matches!(state.stage, Stage::Idle | Stage::Ready | Stage::Finished);
let install_enabled = matches!(state.stage, Stage::Ready);
let prepare_btn = button(text("Download / Prepare"))
.on_press_maybe(prepare_enabled.then_some(Message::PreparePressed));
let install_btn = button(text("Run installer"))
.on_press_maybe(install_enabled.then_some(Message::InstallPressed));
let status = text(state.status.clone());
let body = column![
header,
prefix,
expected,
input,
row![prepare_btn, install_btn].spacing(12),
status,
]
.spacing(12)
.padding(20);
container(body).into()
}
/// Print manual setup steps for a launcher.
///
/// This is a stub until the iced-based setup wizard lands. It walks the
/// user through creating the prefix directory, obtaining the installer,
/// and running it through umu.
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)
.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,
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")
}
fn download_blocking(url: &str, name: &str) -> Result<PathBuf, String> {
let client = reqwest::blocking::Client::builder()
.user_agent("umutray/0.1")
.build()
.map_err(|e| e.to_string())?;
let mut resp = client
.get(url)
.send()
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let base = url.split('?').next().unwrap_or(url);
let candidate = base.rsplit('/').next().filter(|s| s.contains('.')).unwrap_or("");
let filename = if candidate.is_empty() {
format!("{name}-installer.exe")
} else {
candidate.to_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())?;
Ok(tmp)
}
fn run_installer(
config: &Config,
launcher: &Launcher,
installer: &Path,
) -> 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: String = if version == "GE-Proton" {
version.to_string()
let proton_path: std::ffi::OsString = if version == "GE-Proton" {
version.to_string().into()
} else {
config
.proton_compat_dir
.join(version)
.display()
.to_string()
config.proton_compat_dir.join(version).into_os_string()
};
println!("Setup steps for \x1b[1m{}\x1b[0m ({})", launcher.display, launcher.name);
println!();
println!("1. Create the prefix directory:");
println!(" mkdir -p {}", launcher.prefix_dir.display());
println!();
println!("2. Obtain the Windows installer for {}.", launcher.display);
if let Some(url) = &launcher.installer_url {
println!(" (configured source: {url})");
} else {
println!(
" No installer URL is configured for '{}'.",
launcher.name
);
println!(" Download the installer from the vendor and save it locally.");
}
println!();
println!("3. Run the installer under umu (replace INSTALLER.EXE with the path):");
println!(
" WINEPREFIX={} \\",
launcher.prefix_dir.display()
);
println!(" GAMEID={} \\", launcher.gameid);
println!(" PROTONPATH={proton_path} \\");
println!(" umu-run INSTALLER.EXE");
println!();
println!("4. After the installer finishes, verify it placed:");
println!(" {}", launcher.full_exe_path().display());
println!();
println!("5. Then: umutray launch {}", launcher.name);
println!();
println!(
"(A graphical setup wizard via iced is planned — this stub prints the manual\n \
steps in the meantime.)"
);
Ok(())
let status = Command::new("umu-run")
.env("WINEPREFIX", &launcher.prefix_dir)
.env("GAMEID", &launcher.gameid)
.env("PROTONPATH", &proton_path)
.arg(installer)
.status()
.map_err(|e| e.to_string())?;
Ok(status.code().unwrap_or(-1))
}