From 7e5ed3d44785d718a1b1540f79bc8b3e53abccc3 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 16 Apr 2026 21:43:58 -0700 Subject: [PATCH] modified: README.md modified: src/config.rs modified: src/diagnose.rs modified: src/launcher.rs modified: src/main.rs new file: src/setup.rs modified: src/tray.rs --- README.md | 103 +++++++++------ src/config.rs | 266 +++++++++++++++++++++++++++++---------- src/diagnose.rs | 325 +++++++++++++++++++++++------------------------- src/launcher.rs | 101 ++++++++------- src/main.rs | 100 +++++++++++---- src/setup.rs | 60 +++++++++ src/tray.rs | 149 +++++++++++----------- 7 files changed, 690 insertions(+), 414 deletions(-) create mode 100644 src/setup.rs diff --git a/README.md b/README.md index 1fa4af0..f568cdd 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,33 @@ # umutray -A small system-tray daemon and CLI for running the Battle.net launcher on +A small system-tray daemon and CLI for running Windows game launchers on Linux via [umu-launcher](https://github.com/Open-Wine-Components/umu-launcher) and [GE-Proton](https://github.com/GloriousEggroll/proton-ge-custom). +Ships with presets for six launchers out of the box: + +- Battle.net +- EA App +- Epic Games +- Ubisoft Connect +- GOG Galaxy +- Rockstar Games + +Each lives in its own Wine prefix and shows up in the tray with per-launcher +Launch / Kill entries. Users can add or remove launchers in `config edit`. + ## Features -- Tray icon with Launch / Kill / Update-Proton menu (KDE, GNOME+AppIndicator, - Xfce, any SNI-capable desktop). -- Background poller that reflects Battle.net's running state in the tray. -- `update-proton` subcommand that downloads GE-Proton releases directly from - GitHub and installs them under the Steam compat tools directory. -- `diagnose` subcommand that sanity-checks the environment (umu-run, prefix, - Proton install, Vulkan, display server, stale agent.lock). +- Tray icon on any SNI-capable desktop (KDE, GNOME+AppIndicator, Xfce …). +- Per-launcher running state reflected in the tray via a 2 s poller. +- `update-proton` — streams GE-Proton releases straight to disk from GitHub + (no ~600 MB in-memory buffering), with a progress indicator. +- `diagnose` — sanity-checks umu-run, Vulkan, display server, per-launcher + 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). ## Install @@ -21,46 +36,62 @@ cargo build --release install -Dm755 target/release/umutray ~/.local/bin/umutray ``` -Requires `umu-launcher` and `tar` on PATH. On Arch: +Requires `umu-launcher`, `tar`, and `vulkan-tools` on PATH. On Arch: ```sh sudo pacman -S umu-launcher vulkan-tools ``` -The Battle.net Launcher.exe itself is not bundled — run your existing -`battlenet-umu-setup.sh` (or install it manually into the prefix) before -first launch. +Then enable autostart: + +```sh +umutray service install +``` ## Usage -| Command | What it does | -| ------------------------------ | -------------------------------------------------------- | -| `umutray` | Start the tray daemon (default) | -| `umutray launch` | Launch Battle.net and return (for `.desktop` shortcuts) | -| `umutray kill` | SIGTERM → wait 3 s → SIGKILL on all Battle.net procs | -| `umutray diagnose` | Run environment health checks | -| `umutray update-proton` | Interactive GE-Proton picker | -| `update-proton --latest` | Install newest GE-Proton release | -| `update-proton --version X` | Install a specific tag (e.g. `GE-Proton10-34`) | -| `update-proton --list` | Show recent releases without installing | -| `config show` / `config path` | Print current config or its path | -| `config edit` | Open config in `$EDITOR` | -| `config set --prefix PATH` | Change the Wine prefix (also `--compat-dir`, `--gameid`) | -| `service install` | Write + enable a `systemd --user` unit for autostart | -| `service uninstall` / `status` | Remove the unit / show its status | +| Command | What it does | +| -------------------------------- | ------------------------------------------------------- | +| `umutray` | Start the tray daemon (default) | +| `umutray launchers` | List configured launchers and their state | +| `umutray launch ` | Launch a specific launcher (e.g. `umutray launch epic`) | +| `umutray kill []` | Kill one launcher, or all if no name is given | +| `umutray diagnose []` | Health checks (one launcher or all) | +| `umutray setup ` | Print setup steps 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 | +| `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 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` | ## Config -Lives at `~/.config/umutray/config.toml`, written with defaults on -first run: +Lives at `~/.config/umutray/config.toml`. A full config looks like: ```toml -prefix_dir = "~/Games/battlenet-umu" -proton_version = "GE-Proton" # or a pinned tag like "GE-Proton10-34" -gameid = "umu-battlenet" -proton_compat_dir = "~/.local/share/Steam/compatibilitytools.d" +proton_compat_dir = "/home/you/.local/share/Steam/compatibilitytools.d" +proton_version = "GE-Proton" + +[[launchers]] +name = "battlenet" +display = "Battle.net" +prefix_dir = "/home/you/Games/battlenet" +exe_path = "Program Files (x86)/Battle.net/Battle.net Launcher.exe" +gameid = "umu-battlenet" +process_pattern = "Battle\\.net" + +# …one [[launchers]] block per launcher ``` -`proton_version = "GE-Proton"` tells umu-launcher to auto-fetch the latest -on each run. Setting it to a specific tag (done automatically by -`update-proton`) pins that version. +`proton_version = "GE-Proton"` tells umu-launcher to auto-fetch the latest. +Setting it to a pinned tag (done automatically by `update-proton`) uses +that specific version. Each launcher may override the global `proton_version` +with its own. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/src/config.rs b/src/config.rs index 0df6374..b451749 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,37 +4,154 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Config { - /// Wine prefix directory +pub struct Launcher { + /// Short CLI name (e.g. "battlenet"). + pub name: String, + /// Display name for tray / menus. + pub display: String, + /// Wine prefix directory for this launcher. pub prefix_dir: PathBuf, - /// GE-Proton version string passed to PROTONPATH ("GE-Proton" tracks latest) - pub proton_version: String, - /// umu GAMEID used to look up protonfixes + /// Path to the launcher exe, relative to the prefix's drive_c/. + pub exe_path: PathBuf, + /// umu GAMEID (used to look up protonfixes). pub gameid: String, - /// Directory where GE-Proton versions are installed + /// pgrep/pkill -f regex matching this launcher's running processes. + pub process_pattern: String, + /// Optional URL of the Windows installer (consumed by `setup`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub installer_url: Option, + /// Optional per-launcher Proton version override (falls back to + /// Config::proton_version). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proton_version: Option, +} + +impl Launcher { + /// Absolute path to the launcher exe inside the prefix. + pub fn full_exe_path(&self) -> PathBuf { + self.prefix_dir.join("drive_c").join(&self.exe_path) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + /// Directory where GE-Proton versions are installed. + #[serde(default = "default_compat_dir")] pub proton_compat_dir: PathBuf, + /// Default Proton version passed to PROTONPATH. + /// The literal "GE-Proton" makes umu-run fetch/use the latest. + #[serde(default = "default_proton_version")] + pub proton_version: String, + /// Configured launchers. + #[serde(default)] + pub launchers: Vec, +} + +fn home_dir() -> PathBuf { + std::env::var("HOME") + .map(PathBuf::from) + .expect("$HOME is not set; cannot determine default paths") +} + +fn default_compat_dir() -> PathBuf { + home_dir().join(".local/share/Steam/compatibilitytools.d") +} + +fn default_proton_version() -> String { + "GE-Proton".into() +} + +/// 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`. +pub fn presets() -> Vec { + let games = home_dir().join("Games"); + vec![ + Launcher { + name: "battlenet".into(), + display: "Battle.net".into(), + prefix_dir: games.join("battlenet"), + exe_path: PathBuf::from( + "Program Files (x86)/Battle.net/Battle.net Launcher.exe", + ), + gameid: "umu-battlenet".into(), + process_pattern: r"Battle\.net".into(), + installer_url: None, + proton_version: None, + }, + Launcher { + name: "eaapp".into(), + display: "EA App".into(), + prefix_dir: games.join("eaapp"), + exe_path: PathBuf::from( + "Program Files/Electronic Arts/EA Desktop/EA Desktop/EADesktop.exe", + ), + gameid: "umu-eaapp".into(), + process_pattern: r"EADesktop\.exe".into(), + installer_url: None, + proton_version: None, + }, + Launcher { + name: "epic".into(), + display: "Epic Games".into(), + prefix_dir: games.join("epic"), + exe_path: PathBuf::from( + "Program Files (x86)/Epic Games/Launcher/Portal/Binaries/Win32/EpicGamesLauncher.exe", + ), + gameid: "umu-epicgameslauncher".into(), + process_pattern: r"EpicGamesLauncher\.exe".into(), + installer_url: None, + proton_version: None, + }, + Launcher { + name: "ubisoft".into(), + display: "Ubisoft Connect".into(), + prefix_dir: games.join("ubisoft"), + exe_path: PathBuf::from( + "Program Files (x86)/Ubisoft/Ubisoft Game Launcher/UbisoftConnect.exe", + ), + gameid: "umu-uplay".into(), + process_pattern: r"UbisoftConnect\.exe|upc\.exe".into(), + installer_url: None, + proton_version: None, + }, + Launcher { + name: "gog".into(), + display: "GOG Galaxy".into(), + prefix_dir: games.join("gog"), + exe_path: PathBuf::from( + "Program Files (x86)/GOG Galaxy/GalaxyClient.exe", + ), + gameid: "umu-gog".into(), + process_pattern: r"GalaxyClient\.exe".into(), + installer_url: None, + proton_version: None, + }, + Launcher { + name: "rockstar".into(), + display: "Rockstar Games".into(), + prefix_dir: games.join("rockstar"), + exe_path: PathBuf::from( + "Program Files/Rockstar Games/Launcher/Launcher.exe", + ), + gameid: "umu-rockstar".into(), + process_pattern: r"Rockstar Games.*Launcher\.exe".into(), + installer_url: None, + proton_version: None, + }, + ] } impl Default for Config { fn default() -> Self { - let home = home_dir(); Self { - prefix_dir: home.join("Games/battlenet-umu"), - proton_version: "GE-Proton".into(), - gameid: "umu-battlenet".into(), - proton_compat_dir: home.join(".local/share/Steam/compatibilitytools.d"), + proton_compat_dir: default_compat_dir(), + proton_version: default_proton_version(), + launchers: presets(), } } } -fn home_dir() -> PathBuf { - // No sensible fallback — writing a Wine prefix to /tmp would be data-loss - // waiting to happen, so surface the misconfiguration instead. - std::env::var("HOME") - .map(PathBuf::from) - .expect("$HOME is not set; cannot determine default paths") -} - impl Config { pub fn config_path() -> Result { let dirs = ProjectDirs::from("co.aleshym", "", "umutray") @@ -42,7 +159,54 @@ impl Config { Ok(dirs.config_dir().join("config.toml")) } - /// Print the config path followed by the serialised current config. + pub fn load() -> Result { + let path = Self::config_path()?; + if !path.exists() { + let c = Self::default(); + c.save().context("Failed to write default config")?; + return Ok(c); + } + let content = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read config from {path:?}"))?; + match toml::from_str::(&content) { + Ok(mut c) => { + if c.launchers.is_empty() { + c.launchers = presets(); + c.save().context("Failed to write presets")?; + } + Ok(c) + } + Err(e) => { + let bak = path.with_extension("toml.bak"); + std::fs::rename(&path, &bak).with_context(|| { + format!("Failed to back up stale config to {bak:?}") + })?; + eprintln!("warning: couldn't parse {}: {e}", path.display()); + eprintln!( + " backed up to {} — writing fresh config with presets", + bak.display() + ); + let c = Self::default(); + c.save()?; + Ok(c) + } + } + } + + pub fn save(&self) -> Result<()> { + let path = Self::config_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let content = toml::to_string_pretty(self)?; + std::fs::write(&path, content) + .with_context(|| format!("Failed to write config to {path:?}")) + } + + pub fn find(&self, name: &str) -> Option<&Launcher> { + self.launchers.iter().find(|l| l.name == name) + } + pub fn show(&self) -> Result<()> { let path = Self::config_path()?; println!("# {}", path.display()); @@ -51,10 +215,8 @@ impl Config { Ok(()) } - /// Open the config file in $EDITOR (falling back to nano). - /// Ensures the file exists first so the editor isn't launched on nothing. pub fn edit() -> Result<()> { - let _ = Self::load()?; // writes defaults if missing + let _ = Self::load()?; let path = Self::config_path()?; let editor = std::env::var("EDITOR") .or_else(|_| std::env::var("VISUAL")) @@ -69,59 +231,27 @@ impl Config { Ok(()) } - /// Update individual fields non-interactively, then save. - pub fn set_fields( + /// Update global fields non-interactively, then save. + /// Use `config edit` for per-launcher changes. + pub fn set_globals( &mut self, - prefix: Option, + proton_version: Option, compat_dir: Option, - gameid: Option, ) -> Result<()> { - if prefix.is_none() && compat_dir.is_none() && gameid.is_none() { - anyhow::bail!("nothing to set — pass at least one of --prefix / --compat-dir / --gameid"); + if proton_version.is_none() && compat_dir.is_none() { + anyhow::bail!( + "nothing to set — pass --proton-version or --compat-dir" + ); } - if let Some(p) = prefix { - self.prefix_dir = p; + if let Some(v) = proton_version { + self.proton_version = v; } - if let Some(p) = compat_dir { - self.proton_compat_dir = p; - } - if let Some(g) = gameid { - self.gameid = g; + if let Some(d) = compat_dir { + self.proton_compat_dir = d; } self.save()?; println!("\x1b[1;32m✓\x1b[0m Config saved."); println!(); - self.show()?; - Ok(()) - } - - /// Load config from disk, creating a default one if it doesn't exist. - pub fn load() -> Result { - let path = Self::config_path()?; - if !path.exists() { - let config = Self::default(); - config.save().context("Failed to write default config")?; - return Ok(config); - } - let content = std::fs::read_to_string(&path) - .with_context(|| format!("Failed to read config from {path:?}"))?; - toml::from_str(&content).context("Failed to parse config.toml") - } - - /// Write config to disk. - pub fn save(&self) -> Result<()> { - let path = Self::config_path()?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let content = toml::to_string_pretty(self)?; - std::fs::write(&path, content) - .with_context(|| format!("Failed to write config to {path:?}")) - } - - /// Absolute path to the Battle.net Launcher.exe inside the prefix. - pub fn launcher_exe(&self) -> PathBuf { - self.prefix_dir - .join("drive_c/Program Files (x86)/Battle.net/Battle.net Launcher.exe") + self.show() } } diff --git a/src/diagnose.rs b/src/diagnose.rs index 470b036..35b93bd 100644 --- a/src/diagnose.rs +++ b/src/diagnose.rs @@ -1,176 +1,56 @@ -use crate::config::Config; +use crate::config::{Config, Launcher}; +use anyhow::Result; +use std::os::unix::fs::MetadataExt; use std::path::Path; -use std::process::Command; +use std::process::{Command, Stdio}; struct Check { - label: &'static str, + label: String, pass: bool, detail: String, } impl Check { - fn pass(label: &'static str, detail: impl Into) -> Self { - Self { label, pass: true, detail: detail.into() } + fn pass(label: impl Into, detail: impl Into) -> Self { + Self { label: label.into(), pass: true, detail: detail.into() } } - fn fail(label: &'static str, detail: impl Into) -> Self { - Self { label, pass: false, detail: detail.into() } + fn fail(label: impl Into, detail: impl Into) -> Self { + Self { label: label.into(), pass: false, detail: detail.into() } } } -pub fn run(config: &Config) { - let mut checks: Vec = Vec::new(); +pub fn run(config: &Config, name: Option<&str>) -> Result<()> { + let mut checks: Vec = vec![ + global_umu_check(), + global_vulkan_check(), + global_display_check(), + compat_dir_check(config), + ]; + + let launchers: Vec<&Launcher> = if let Some(n) = name { + let l = config + .find(n) + .ok_or_else(|| anyhow::anyhow!("unknown launcher '{n}'"))?; + vec![l] + } else { + config.launchers.iter().collect() + }; + for l in launchers { + checks.extend(launcher_checks(l)); + } + let mut issues = 0u32; - - // ── umu-run ────────────────────────────────────────────────────────────── - match which("umu-run") { - Some(p) => checks.push(Check::pass("umu-run", format!("found at {p}"))), - None => { - checks.push(Check::fail("umu-run", "not found — sudo pacman -S umu-launcher")); - issues += 1; - } - } - - // ── Wine prefix ────────────────────────────────────────────────────────── - if config.prefix_dir.exists() { - checks.push(Check::pass("prefix", format!("{:?}", config.prefix_dir))); - - let exe = config.launcher_exe(); - if exe.exists() { - checks.push(Check::pass("launcher", "Battle.net Launcher.exe present")); - } else { - checks.push(Check::fail( - "launcher", - format!("not found at {exe:?} — run battlenet-umu-setup.sh"), - )); - issues += 1; - } - - // Stale agent lock - let lock = config.prefix_dir - .join("drive_c/ProgramData/Battle.net/Agent/agent.lock"); - if lock.exists() { - if let Ok(meta) = std::fs::metadata(&lock) { - if let Ok(modified) = meta.modified() { - let age = std::time::SystemTime::now() - .duration_since(modified) - .unwrap_or_default() - .as_secs(); - if age > 300 { - checks.push(Check::fail( - "agent.lock", - format!("stale lock ({age}s old) — may cause BLZBNTBNA00000005; run: umutray kill"), - )); - issues += 1; - } else { - checks.push(Check::pass("agent.lock", format!("fresh ({age}s) — launcher may be running"))); - } - } - } - } - - // Prefix ownership - if is_owned_by_current_user(&config.prefix_dir) { - checks.push(Check::pass("prefix owner", "owned by current user")); - } else { - checks.push(Check::fail( - "prefix owner", - "not owned by current user — Wine will misbehave", - )); - issues += 1; - } - } else { - checks.push(Check::fail( - "prefix", - format!("{:?} does not exist — run battlenet-umu-setup.sh", config.prefix_dir), - )); - issues += 1; - } - - // ── GE-Proton ──────────────────────────────────────────────────────────── - let installed_count = count_ge_proton(&config.proton_compat_dir); - if config.proton_version == "GE-Proton" { - if installed_count > 0 { - checks.push(Check::pass( - "proton", - format!("tracking latest; {installed_count} version(s) in {:?}", config.proton_compat_dir), - )); - } else { - checks.push(Check::pass( - "proton", - "tracking latest — will auto-download on first launch", - )); - } - } else { - let vpath = config.proton_compat_dir.join(&config.proton_version); - if vpath.exists() { - checks.push(Check::pass("proton", format!("{} installed", config.proton_version))); - } else { - checks.push(Check::fail( - "proton", - format!( - "{} not found — run: umutray update-proton --version={}", - config.proton_version, config.proton_version - ), - )); - issues += 1; - } - } - - // ── Vulkan ─────────────────────────────────────────────────────────────── - let vulkan_ok = Command::new("vulkaninfo") - .arg("--summary") - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if vulkan_ok { - checks.push(Check::pass("vulkan", "vulkaninfo OK")); - } else { - checks.push(Check::fail("vulkan", "vulkaninfo failed or not installed — check GPU drivers and vulkan-tools")); - issues += 1; - } - - // ── Display / XWayland ─────────────────────────────────────────────────── - let display = std::env::var("DISPLAY").ok(); - let wayland = std::env::var("WAYLAND_DISPLAY").ok(); - match (display, wayland) { - (Some(d), Some(_)) => checks.push(Check::pass("display", format!("XWayland (DISPLAY={d})"))), - (Some(d), None) => checks.push(Check::pass("display", format!("X11 (DISPLAY={d})"))), - (None, Some(_)) => { - checks.push(Check::fail( - "display", - "Wayland session but DISPLAY not set — XWayland not running; Battle.net needs it", - )); - issues += 1; - } - (None, None) => { - checks.push(Check::fail("display", "neither DISPLAY nor WAYLAND_DISPLAY is set")); - issues += 1; - } - } - - // ── Running processes ──────────────────────────────────────────────────── - let bnet_running = Command::new("pgrep") - .args(["-fi", "Battle\\.net"]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false); - if bnet_running { - checks.push(Check::pass("process", "Battle.net is currently running")); - } else { - checks.push(Check::pass("process", "Battle.net is not running")); - } - - // ── Print ──────────────────────────────────────────────────────────────── println!(); for c in &checks { - let (symbol, colour, reset) = if c.pass { + if !c.pass { + issues += 1; + } + let (sym, col, rst) = if c.pass { ("✓", "\x1b[1;32m", "\x1b[0m") } else { ("✗", "\x1b[1;31m", "\x1b[0m") }; - println!(" {colour}{symbol}{reset} {:16} {}", c.label, c.detail); + println!(" {col}{sym}{rst} {:24} {}", c.label, c.detail); } println!(); if issues == 0 { @@ -179,18 +59,120 @@ pub fn run(config: &Config) { println!(" \x1b[1;31m{issues} issue(s) found — see ✗ items above.\x1b[0m"); } println!(); + Ok(()) } -// ── helpers ────────────────────────────────────────────────────────────────── +fn global_umu_check() -> Check { + match which("umu-run") { + Some(p) => Check::pass("umu-run", format!("found at {p}")), + None => Check::fail("umu-run", "not found — install umu-launcher"), + } +} -fn which(cmd: &str) -> Option { - Command::new("which") - .arg(cmd) - .output() - .ok() - .filter(|o| o.status.success()) - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_string()) +fn global_vulkan_check() -> Check { + let ok = Command::new("vulkaninfo") + .arg("--summary") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if ok { + Check::pass("vulkan", "vulkaninfo OK") + } else { + Check::fail("vulkan", "vulkaninfo failed — check GPU drivers / vulkan-tools") + } +} + +fn global_display_check() -> Check { + let display = std::env::var("DISPLAY").ok(); + let wayland = std::env::var("WAYLAND_DISPLAY").ok(); + match (display, wayland) { + (Some(d), Some(_)) => Check::pass("display", format!("XWayland (DISPLAY={d})")), + (Some(d), None) => Check::pass("display", format!("X11 (DISPLAY={d})")), + (None, Some(_)) => Check::fail( + "display", + "Wayland session but DISPLAY unset; XWayland needed", + ), + (None, None) => Check::fail("display", "neither DISPLAY nor WAYLAND_DISPLAY set"), + } +} + +fn compat_dir_check(config: &Config) -> Check { + let n = count_ge_proton(&config.proton_compat_dir); + if config.proton_version == "GE-Proton" { + Check::pass( + "proton", + format!( + "tracking latest; {n} version(s) in {}", + config.proton_compat_dir.display() + ), + ) + } else { + let path = config.proton_compat_dir.join(&config.proton_version); + if path.exists() { + Check::pass("proton", format!("{} installed", config.proton_version)) + } else { + Check::fail( + "proton", + format!( + "{} missing — run: umutray update-proton --version={}", + config.proton_version, config.proton_version + ), + ) + } + } +} + +fn launcher_checks(l: &Launcher) -> Vec { + let mut out = Vec::new(); + let tag = format!("[{}]", l.name); + + if !l.prefix_dir.exists() { + out.push(Check::fail( + format!("{tag} prefix"), + format!( + "{} missing — run: umutray setup {}", + l.prefix_dir.display(), + l.name + ), + )); + return out; + } + out.push(Check::pass( + format!("{tag} prefix"), + l.prefix_dir.display().to_string(), + )); + + let exe = l.full_exe_path(); + if exe.exists() { + out.push(Check::pass(format!("{tag} exe"), "installed")); + } else { + out.push(Check::fail( + format!("{tag} exe"), + format!("missing — run: umutray setup {}", l.name), + )); + } + + if is_owned_by_current_user(&l.prefix_dir) { + out.push(Check::pass( + format!("{tag} owner"), + "owned by current user", + )); + } else { + out.push(Check::fail( + format!("{tag} owner"), + "not owned by current user", + )); + } + + if crate::launcher::is_running(l) { + out.push(Check::pass(format!("{tag} process"), "currently running")); + } else { + out.push(Check::pass(format!("{tag} process"), "not running")); + } + + out } fn count_ge_proton(dir: &Path) -> usize { @@ -210,22 +192,27 @@ fn count_ge_proton(dir: &Path) -> usize { .unwrap_or(0) } -fn is_owned_by_current_user(path: &Path) -> bool { - use std::os::unix::fs::MetadataExt; +fn which(cmd: &str) -> Option { + Command::new("which") + .arg(cmd) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) +} +fn is_owned_by_current_user(path: &Path) -> bool { let file_uid = match std::fs::metadata(path) { Ok(m) => m.uid(), - Err(_) => return true, // assume OK if we can't check + Err(_) => return true, }; - - // No std API for the current process uid; shell out once to `id -u`. let current_uid: Option = Command::new("id") .arg("-u") .output() .ok() .and_then(|o| String::from_utf8(o.stdout).ok()) .and_then(|s| s.trim().parse().ok()); - match current_uid { Some(uid) => uid == file_uid, None => true, diff --git a/src/launcher.rs b/src/launcher.rs index 498556d..c538361 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -1,30 +1,36 @@ -use crate::config::Config; -use anyhow::{Context, Result}; +use crate::config::{Config, Launcher}; +use anyhow::{bail, Context, Result}; +use std::process::Stdio; use std::thread; use std::time::Duration; -/// Spawn Battle.net via umu-run and return immediately. -pub fn launch(config: &Config) -> Result<()> { - let exe = config.launcher_exe(); - +/// Spawn the launcher via umu-run and return immediately. +pub fn launch(config: &Config, launcher: &Launcher) -> Result<()> { + let exe = launcher.full_exe_path(); if !exe.exists() { - anyhow::bail!( - "Battle.net Launcher.exe not found at {exe:?}\n\ - Run 'battlenet-umu-setup.sh' to install it first." + bail!( + "launcher exe not found at {:?}\n\ + Run `umutray setup {}` for setup instructions.", + exe, + launcher.name, ); } // PROTONPATH: umu-run accepts the literal "GE-Proton" to auto-fetch the // latest; for any pinned version it expects a full path to the install dir. - let proton_path: std::ffi::OsString = if config.proton_version == "GE-Proton" { - config.proton_version.clone().into() + let version = launcher + .proton_version + .as_deref() + .unwrap_or(&config.proton_version); + let proton_path: std::ffi::OsString = if version == "GE-Proton" { + version.to_string().into() } else { - config.proton_compat_dir.join(&config.proton_version).into_os_string() + config.proton_compat_dir.join(version).into_os_string() }; std::process::Command::new("umu-run") - .env("WINEPREFIX", &config.prefix_dir) - .env("GAMEID", &config.gameid) + .env("WINEPREFIX", &launcher.prefix_dir) + .env("GAMEID", &launcher.gameid) .env("PROTONPATH", &proton_path) .arg(&exe) .spawn() @@ -36,40 +42,47 @@ pub fn launch(config: &Config) -> Result<()> { Ok(()) } -/// Gracefully stop Battle.net: SIGTERM → wait 3 s → SIGKILL. -pub fn kill() -> Result<()> { - let patterns = ["Battle\\.net", "Agent\\.exe", "Blizzard"]; - - for pattern in &patterns { - let _ = std::process::Command::new("pkill") - .args(["-15", "-f", pattern]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(); - } - - thread::sleep(Duration::from_secs(3)); - - for pattern in &patterns { - let _ = std::process::Command::new("pkill") - .args(["-9", "-f", pattern]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(); - } - +/// SIGTERM → wait 3 s → SIGKILL for a single launcher. +pub fn kill(launcher: &Launcher) -> Result<()> { + kill_pattern(&launcher.process_pattern); Ok(()) } -/// Returns true if any Battle.net process is currently running. -pub fn is_running() -> bool { - // Escape the dot — unescaped, "battle.net" would regex-match literally - // any process with "battlenet" in its cmdline (including this binary if - // it were ever renamed back to battlenet-*). +/// Kill every configured launcher's processes. +pub fn kill_all(config: &Config) -> Result<()> { + // Single SIGTERM pass across all launchers, then one sleep, then SIGKILL. + // This keeps the total wait at 3 s instead of 3 s × N. + for l in &config.launchers { + send_signal("-15", &l.process_pattern); + } + thread::sleep(Duration::from_secs(3)); + for l in &config.launchers { + send_signal("-9", &l.process_pattern); + } + Ok(()) +} + +fn kill_pattern(pattern: &str) { + send_signal("-15", pattern); + thread::sleep(Duration::from_secs(3)); + send_signal("-9", pattern); +} + +fn send_signal(sig: &str, pattern: &str) { + let _ = std::process::Command::new("pkill") + .args([sig, "-f", pattern]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + +/// Returns true if at least one process matching the launcher's pattern +/// is currently alive. +pub fn is_running(launcher: &Launcher) -> bool { std::process::Command::new("pgrep") - .args(["-fi", "Battle\\.net"]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) + .args(["-f", &launcher.process_pattern]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false) diff --git a/src/main.rs b/src/main.rs index 2600ee7..70adfd4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,16 +3,16 @@ mod diagnose; mod launcher; mod proton; mod service; +mod setup; mod tray; use anyhow::Result; use clap::{Parser, Subcommand}; use std::path::PathBuf; -/// Battle.net launcher manager for Linux via umu/Proton-GE. +/// Tray-based Wine launcher manager for Linux via umu/Proton-GE. /// /// Running without a subcommand starts the system tray daemon. -/// Use `launch` in your .desktop shortcut for a direct, no-UI launch. #[derive(Parser)] #[command(name = "umutray", version, about)] struct Cli { @@ -25,14 +25,32 @@ enum Commands { /// Start the system tray daemon (default when no subcommand given) Tray, - /// Launch Battle.net immediately and return — use this in .desktop shortcuts - Launch, + /// Launch a configured launcher + Launch { + /// Launcher name (e.g. battlenet, eaapp, epic) + name: String, + }, - /// Gracefully kill all Battle.net / Wine processes - Kill, + /// Kill a specific launcher, or every configured one if no name is given + Kill { + /// Launcher name (omit to kill all) + name: Option, + }, - /// Check setup health and report any problems - Diagnose, + /// Run health checks on a specific launcher, or all of them + Diagnose { + /// Launcher name (omit to check all) + name: Option, + }, + + /// List configured launchers and whether they're installed / running + Launchers, + + /// Print setup instructions for a launcher (automated wizard coming soon) + Setup { + /// Launcher name + name: String, + }, /// Download and switch GE-Proton versions UpdateProton { @@ -40,7 +58,7 @@ enum Commands { #[arg(long)] latest: bool, - /// Install a specific version (e.g. GE-Proton9-20) + /// Install a specific version (e.g. GE-Proton10-34) #[arg(long, value_name = "VERSION")] version: Option, @@ -70,19 +88,15 @@ enum ConfigAction { Path, /// Open the config file in $EDITOR Edit, - /// Update one or more fields non-interactively + /// Update global fields. Use `config edit` for per-launcher changes. Set { - /// Wine prefix directory - #[arg(long, value_name = "PATH")] - prefix: Option, + /// Default Proton version (e.g. GE-Proton, GE-Proton10-34) + #[arg(long, value_name = "VERSION")] + proton_version: Option, /// GE-Proton install directory #[arg(long, value_name = "PATH")] compat_dir: Option, - - /// umu GAMEID (used for protonfixes lookup) - #[arg(long, value_name = "ID")] - gameid: Option, }, } @@ -102,23 +116,65 @@ fn main() -> Result<()> { match cli.command.unwrap_or(Commands::Tray) { Commands::Tray => tray::run(&config)?, - Commands::Launch => launcher::launch(&config)?, - Commands::Kill => launcher::kill()?, - Commands::Diagnose => diagnose::run(&config), + + Commands::Launch { name } => { + let l = config.find(&name).ok_or_else(|| { + anyhow::anyhow!("unknown launcher '{name}' — try `umutray launchers`") + })?; + launcher::launch(&config, l)?; + } + + Commands::Kill { name } => match name { + Some(n) => { + let l = config.find(&n).ok_or_else(|| { + anyhow::anyhow!("unknown launcher '{n}' — try `umutray launchers`") + })?; + launcher::kill(l)?; + } + None => launcher::kill_all(&config)?, + }, + + Commands::Diagnose { name } => { + diagnose::run(&config, name.as_deref())?; + } + + Commands::Launchers => { + for l in &config.launchers { + let installed = l.full_exe_path().exists(); + let running = launcher::is_running(l); + let marker = if installed { + "\x1b[1;32m✓\x1b[0m" + } else { + "·" + }; + let state = if running { " (running)" } else { "" }; + println!(" {marker} {:12} {}{}", l.name, l.display, state); + } + } + + Commands::Setup { name } => { + let l = config.find(&name).ok_or_else(|| { + anyhow::anyhow!("unknown launcher '{name}' — try `umutray launchers`") + })?; + setup::run(&config, l)?; + } + Commands::UpdateProton { latest, version, list } => { proton::run(&config, latest, version, list)?; } + Commands::Config { action } => match action { ConfigAction::Show => config.show()?, ConfigAction::Path => { println!("{}", config::Config::config_path()?.display()); } ConfigAction::Edit => config::Config::edit()?, - ConfigAction::Set { prefix, compat_dir, gameid } => { + ConfigAction::Set { proton_version, compat_dir } => { let mut c = config; - c.set_fields(prefix, compat_dir, gameid)?; + c.set_globals(proton_version, compat_dir)?; } }, + Commands::Service { action } => match action { ServiceAction::Install => service::install()?, ServiceAction::Uninstall => service::uninstall()?, diff --git a/src/setup.rs b/src/setup.rs new file mode 100644 index 0000000..3fe68d9 --- /dev/null +++ b/src/setup.rs @@ -0,0 +1,60 @@ +use crate::config::{Config, Launcher}; +use anyhow::Result; + +/// 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 version = launcher + .proton_version + .as_deref() + .unwrap_or(&config.proton_version); + let proton_path: String = if version == "GE-Proton" { + version.to_string() + } else { + config + .proton_compat_dir + .join(version) + .display() + .to_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(()) +} diff --git a/src/tray.rs b/src/tray.rs index 8aabf96..9e1b0a0 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -1,97 +1,92 @@ use crate::{config::Config, launcher}; use anyhow::Result; +use std::collections::HashMap; use std::thread; use std::time::Duration; -pub struct BattlenetTray { +pub struct UmuTray { pub config: Config, - /// Whether Battle.net is currently running; updated by background poller. - pub running: bool, + /// Per-launcher running state keyed by launcher.name + pub running: HashMap, /// Set after the service spawns so Quit can shut down the SNI item /// cleanly instead of yanking it off the bus via exit(). - pub handle: Option>, + pub handle: Option>, } -impl ksni::Tray for BattlenetTray { +impl ksni::Tray for UmuTray { fn id(&self) -> String { "umutray".into() } fn icon_name(&self) -> String { - // Use the extracted Battle.net icon if available, fall back to a generic one. - let icon_path = std::env::var("HOME") - .map(std::path::PathBuf::from) - .unwrap_or_default() - .join(".local/share/icons/hicolor/256x256/apps/battlenet.png"); - - if icon_path.exists() { - "battlenet".into() - } else { - "applications-games".into() - } + "applications-games".into() } fn title(&self) -> String { - if self.running { - "Battle.net (running)".into() + if self.running.values().any(|&v| v) { + "umutray (launcher running)".into() } else { - "Battle.net".into() + "umutray".into() } } fn menu(&self) -> Vec> { use ksni::menu::*; - let mut items: Vec> = vec![]; - // Status label (non-interactive) - items.push( - StandardItem { - label: if self.running { - "● Battle.net is running".into() - } else { - "Battle.net".into() - }, - enabled: false, - ..Default::default() + for l in &self.config.launchers { + let installed = l.full_exe_path().exists(); + let running = *self.running.get(&l.name).unwrap_or(&false); + let name = l.name.clone(); + let display = l.display.clone(); + + if !installed { + items.push( + StandardItem { + label: format!("{display} (not installed)"), + enabled: false, + ..Default::default() + } + .into(), + ); + continue; } - .into(), - ); - items.push(ksni::MenuItem::Separator); - - // Launch / Kill - if self.running { - items.push( - StandardItem { - label: "Kill Battle.net".into(), - icon_name: "process-stop".into(), - activate: Box::new(|_this: &mut Self| { - let _ = launcher::kill(); - }), - ..Default::default() - } - .into(), - ); - } else { - items.push( - StandardItem { - label: "Launch Battle.net".into(), - icon_name: "media-playback-start".into(), - activate: Box::new(|this: &mut Self| { - if let Err(e) = launcher::launch(&this.config) { - eprintln!("umutray: launch failed: {e}"); - } - }), - ..Default::default() - } - .into(), - ); + if running { + items.push( + StandardItem { + label: format!("Kill {display}"), + icon_name: "process-stop".into(), + activate: Box::new(move |this: &mut Self| { + if let Some(l) = this.config.find(&name) { + let _ = launcher::kill(l); + } + }), + ..Default::default() + } + .into(), + ); + } else { + items.push( + StandardItem { + label: format!("Launch {display}"), + icon_name: "media-playback-start".into(), + activate: Box::new(move |this: &mut Self| { + if let Some(l) = this.config.find(&name) { + if let Err(e) = launcher::launch(&this.config, l) { + eprintln!("umutray: launch {} failed: {e}", l.name); + } + } + }), + ..Default::default() + } + .into(), + ); + } } items.push(ksni::MenuItem::Separator); - // Update Proton (latest, background) items.push( StandardItem { label: "Update GE-Proton (latest)".into(), @@ -111,16 +106,12 @@ impl ksni::Tray for BattlenetTray { items.push(ksni::MenuItem::Separator); - // Quit items.push( StandardItem { label: "Quit".into(), icon_name: "application-exit".into(), activate: Box::new(|this: &mut Self| { if let Some(h) = this.handle.clone() { - // Run shutdown off-thread: we're currently holding - // the tray lock inside update(), and shutdown wants - // the service loop to turn. thread::spawn(move || { h.shutdown(); std::process::exit(0); @@ -140,34 +131,42 @@ impl ksni::Tray for BattlenetTray { /// Start the system tray daemon. Blocks until the process is killed. pub fn run(config: &Config) -> Result<()> { - let tray = BattlenetTray { + let mut running = HashMap::new(); + for l in &config.launchers { + running.insert(l.name.clone(), launcher::is_running(l)); + } + + let tray = UmuTray { config: config.clone(), - running: launcher::is_running(), + running, handle: None, }; let service = ksni::TrayService::new(tray); - // Grab a handle before spawn() consumes the service. let handle = service.handle(); service.spawn(); // Hand the tray a clone of its own handle so Quit can shut down cleanly. let handle_for_self = handle.clone(); - handle.update(move |t: &mut BattlenetTray| { + handle.update(move |t: &mut UmuTray| { t.handle = Some(handle_for_self); }); - // Background thread: poll Battle.net process state every 2 s and update the tray. + // Background thread: poll every configured launcher's state every 2 s + // and push the snapshot to the tray. let poll_handle = handle; + let launchers = config.launchers.clone(); thread::spawn(move || loop { - let running = launcher::is_running(); - poll_handle.update(|tray: &mut BattlenetTray| { - tray.running = running; + let mut snapshot: HashMap = HashMap::new(); + for l in &launchers { + snapshot.insert(l.name.clone(), launcher::is_running(l)); + } + poll_handle.update(move |t: &mut UmuTray| { + t.running = snapshot; }); thread::sleep(Duration::from_secs(2)); }); - // Keep the main thread alive. loop { thread::sleep(Duration::from_secs(60)); }