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
|
# HTTP for GE-Proton GitHub releases API
|
||||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
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.
|
prefix / exe / ownership / running state.
|
||||||
- `service` — installs a `systemd --user` unit so the tray autostarts with
|
- `service` — installs a `systemd --user` unit so the tray autostarts with
|
||||||
the graphical session.
|
the graphical session.
|
||||||
- `setup` — prints the manual setup steps for a launcher (a graphical
|
- `setup` — graphical wizard (iced) that downloads an installer URL or
|
||||||
wizard via iced is planned).
|
accepts a local `.exe`, then runs it via `umu-run` in the launcher's
|
||||||
|
Wine prefix.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ umutray service install
|
|||||||
| `umutray launch <name>` | Launch a specific launcher (e.g. `umutray launch epic`) |
|
| `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 kill [<name>]` | Kill one launcher, or all if no name is given |
|
||||||
| `umutray diagnose [<name>]` | Health checks (one launcher or all) |
|
| `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 --latest` | Install newest GE-Proton release |
|
||||||
| `umutray update-proton --list` | Show recent releases without installing |
|
| `umutray update-proton --list` | Show recent releases without installing |
|
||||||
| `umutray update-proton` | Interactive version picker |
|
| `umutray update-proton` | Interactive version picker |
|
||||||
|
|||||||
+235
-49
@@ -1,60 +1,246 @@
|
|||||||
use crate::config::{Config, Launcher};
|
use crate::config::{Config, Launcher};
|
||||||
use anyhow::Result;
|
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<()> {
|
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
|
let version = launcher
|
||||||
.proton_version
|
.proton_version
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or(&config.proton_version);
|
.unwrap_or(&config.proton_version);
|
||||||
let proton_path: String = if version == "GE-Proton" {
|
let proton_path: std::ffi::OsString = if version == "GE-Proton" {
|
||||||
version.to_string()
|
version.to_string().into()
|
||||||
} else {
|
} else {
|
||||||
config
|
config.proton_compat_dir.join(version).into_os_string()
|
||||||
.proton_compat_dir
|
|
||||||
.join(version)
|
|
||||||
.display()
|
|
||||||
.to_string()
|
|
||||||
};
|
};
|
||||||
|
let status = Command::new("umu-run")
|
||||||
println!("Setup steps for \x1b[1m{}\x1b[0m ({})", launcher.display, launcher.name);
|
.env("WINEPREFIX", &launcher.prefix_dir)
|
||||||
println!();
|
.env("GAMEID", &launcher.gameid)
|
||||||
println!("1. Create the prefix directory:");
|
.env("PROTONPATH", &proton_path)
|
||||||
println!(" mkdir -p {}", launcher.prefix_dir.display());
|
.arg(installer)
|
||||||
println!();
|
.status()
|
||||||
println!("2. Obtain the Windows installer for {}.", launcher.display);
|
.map_err(|e| e.to_string())?;
|
||||||
if let Some(url) = &launcher.installer_url {
|
Ok(status.code().unwrap_or(-1))
|
||||||
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(())
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user