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:
Generated
+3552
-27
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user