diff --git a/Cargo.lock b/Cargo.lock index 3ce8b64..a076217 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1827,6 +1827,7 @@ dependencies = [ "iced_core", "log", "rustc-hash 2.1.2", + "tokio", "wasm-bindgen-futures", "wasm-timer", ] diff --git a/Cargo.toml b/Cargo.toml index 16a2906..a05bdc1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/README.md b/README.md index 4e64644..098b94c 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/src/config.rs b/src/config.rs index b451749..d219072 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, + exe_path: PathBuf, + prefix_dir: Option, + gameid: Option, + process_pattern: Option, + installer_url: Option, + ) -> 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( diff --git a/src/diagnose.rs b/src/diagnose.rs index 35b93bd..06b47bd 100644 --- a/src/diagnose.rs +++ b/src/diagnose.rs @@ -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::().ok()) + .unwrap_or(0) +} + fn launcher_checks(l: &Launcher) -> Vec { let mut out = Vec::new(); let tag = format!("[{}]", l.name); diff --git a/src/main.rs b/src/main.rs index 70adfd4..4356038 100644 --- a/src/main.rs +++ b/src/main.rs @@ -98,6 +98,40 @@ enum ConfigAction { #[arg(long, value_name = "PATH")] compat_dir: Option, }, + /// 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, + + /// Wine prefix dir (defaults to ~/Games/NAME) + #[arg(long, value_name = "PATH")] + prefix_dir: Option, + + /// umu GAMEID (defaults to "umu-NAME") + #[arg(long)] + gameid: Option, + + /// pgrep -f regex (defaults to escaped exe basename) + #[arg(long)] + process_pattern: Option, + + /// Optional installer URL + #[arg(long)] + installer_url: Option, + }, + /// 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 { diff --git a/src/setup.rs b/src/setup.rs index d716962..b26c323 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -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), InstallPressed, InstallDone(Result), + Tick, } #[derive(Debug, Clone)] @@ -24,6 +31,12 @@ enum Stage { Finished, } +#[derive(Debug, Clone, Default)] +struct DownloadProgress { + bytes: u64, + total: Option, +} + struct State { config: Config, launcher: Launcher, @@ -31,6 +44,8 @@ struct State { installer: Option, stage: Stage, status: String, + download: Arc>, + log: Arc>>, } 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 { 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 { 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 { } 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 { 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 { } } +fn subscription(state: &State) -> Subscription { + 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 = 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 = if matches!(state.stage, Stage::Installing | Stage::Finished) { + let lines: Vec = state.log.lock().map(|v| v.clone()).unwrap_or_default(); + let tail: Vec> = 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(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 { +fn download_blocking( + url: &str, + name: &str, + progress: Arc>, +) -> Result { let client = reqwest::blocking::Client::builder() .user_agent("umutray/0.1") .build() @@ -207,8 +284,17 @@ fn download_blocking(url: &str, name: &str) -> Result { .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 { }; 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>>, ) -> Result { 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: R, log: Arc>>) { + 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") + } +} diff --git a/src/tray.rs b/src/tray.rs index 9e1b0a0..1157a58 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -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(),