use anyhow::{Context, Result}; use directories::ProjectDirs; use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize)] 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, /// 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, /// 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, /// Games installed through this launcher. Overlays (gamemode, mangohud, /// gamescope) only apply to games — the launcher itself always runs bare. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub games: Vec, } 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) } pub fn find_game(&self, name: &str) -> Option<&Game> { self.games.iter().find(|g| g.name == name) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Game { /// Short CLI name (e.g. "overwatch"). pub name: String, /// Display name for tray / menus. pub display: String, /// Path to the game exe relative to the launcher's prefix `drive_c/`. pub exe_path: PathBuf, /// Optional extra args passed to the game exe after umu-run. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub args: Vec, /// Wrap the game in `gamemoderun`. #[serde(default)] pub gamemode: bool, /// Set `MANGOHUD=1` for the game process. #[serde(default)] pub mangohud: bool, /// Wrap the game in `gamescope`. `None` = disabled; `Some(vec)` = enabled /// with those CLI args (empty vec = gamescope defaults). #[serde(default, skip_serializing_if = "Option::is_none")] pub gamescope: Option>, } impl Game { /// Absolute path to the game exe inside the launcher's prefix. pub fn full_exe_path(&self, launcher: &Launcher) -> PathBuf { launcher.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 { dirs::home_dir().expect("Cannot determine home directory") } fn default_compat_dir() -> PathBuf { home_dir().join(".local/share/Steam/compatibilitytools.d") } 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`. 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: Some( "https://www.battle.net/download/getInstallerForGame?os=win&gameProgram=BATTLENET_APP&version=Live".into(), ), proton_version: None, games: vec![], }, 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: Some( "https://origin-a.akamaihd.net/EA-Desktop-Client-Download/installer-releases/EAappInstaller.exe".into(), ), proton_version: None, games: vec![], }, 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: Some( "https://launcher-public-service-prod06.ol.epicgames.com/launcher/api/installer/download/EpicGamesLauncherInstaller.msi".into(), ), proton_version: None, games: vec![], }, 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: Some( "https://ubistatic3-a.akamaihd.net/orbit/launcher_installer/UbisoftConnectInstaller.exe".into(), ), proton_version: None, games: vec![], }, 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: Some( "https://webinstallers.gog-statics.com/download/GOG_Galaxy_2.0.exe".into(), ), proton_version: None, games: vec![], }, 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: Some( "https://gamedownloads.rockstargames.com/public/installer/Rockstar-Games-Launcher.exe".into(), ), proton_version: None, games: vec![], }, ] } impl Default for Config { fn default() -> Self { Self { proton_compat_dir: default_compat_dir(), proton_version: default_proton_version(), launchers: vec![], } } } impl Config { pub fn config_path() -> Result { let dirs = ProjectDirs::from("co.aleshym", "", "umutray") .context("Could not determine config directory")?; Ok(dirs.config_dir().join("config.toml")) } 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(c) => { 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()); let s = toml::to_string_pretty(self)?; print!("{s}"); Ok(()) } pub fn edit() -> Result<()> { let _ = Self::load()?; let path = Self::config_path()?; let editor = std::env::var("EDITOR") .or_else(|_| std::env::var("VISUAL")) .unwrap_or_else(|_| "nano".into()); let status = std::process::Command::new(&editor) .arg(&path) .status() .with_context(|| format!("Failed to spawn editor '{editor}'"))?; if !status.success() { anyhow::bail!("Editor '{editor}' exited non-zero"); } 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, games: vec![], }); self.save()?; println!("{} Added launcher '{name}'.", "✓".green().bold()); Ok(()) } #[allow(clippy::too_many_arguments)] pub fn add_game( &mut self, launcher: &str, name: String, display: Option, exe_path: PathBuf, gamemode: bool, mangohud: bool, gamescope: Option>, ) -> Result<()> { let l = self .launchers .iter_mut() .find(|l| l.name == launcher) .ok_or_else(|| anyhow::anyhow!("no launcher named '{launcher}'"))?; if l.games.iter().any(|g| g.name == name) { anyhow::bail!("launcher '{launcher}' already has a game named '{name}'"); } let display = display.unwrap_or_else(|| name.clone()); l.games.push(Game { name: name.clone(), display, exe_path, args: vec![], gamemode, mangohud, gamescope, }); self.save()?; println!("{} Added game '{name}' under launcher '{launcher}'.", "✓".green().bold()); Ok(()) } pub fn remove_game(&mut self, launcher: &str, name: &str) -> Result<()> { let l = self .launchers .iter_mut() .find(|l| l.name == launcher) .ok_or_else(|| anyhow::anyhow!("no launcher named '{launcher}'"))?; let before = l.games.len(); l.games.retain(|g| g.name != name); if l.games.len() == before { anyhow::bail!("launcher '{launcher}' has no game named '{name}'"); } self.save()?; println!("{} Removed game '{name}' from '{launcher}'.", "✓".green().bold()); Ok(()) } /// Update per-game overlay flags. Each arg is `None` = leave as-is. /// `gamescope = Some(None)` disables it; `Some(Some(vec))` enables with args. pub fn set_game_flags( &mut self, launcher: &str, name: &str, gamemode: Option, mangohud: Option, gamescope: Option>>, ) -> Result<()> { if gamemode.is_none() && mangohud.is_none() && gamescope.is_none() { anyhow::bail!( "nothing to set — pass --gamemode, --mangohud, --gamescope, or --no-gamescope" ); } let l = self .launchers .iter_mut() .find(|l| l.name == launcher) .ok_or_else(|| anyhow::anyhow!("no launcher named '{launcher}'"))?; let g = l.games.iter_mut().find(|g| g.name == name).ok_or_else(|| { anyhow::anyhow!("launcher '{launcher}' has no game named '{name}'") })?; if let Some(v) = gamemode { g.gamemode = v; } if let Some(v) = mangohud { g.mangohud = v; } if let Some(v) = gamescope { g.gamescope = v; } self.save()?; println!("{} Updated flags for '{launcher}/{name}'.", "✓".green().bold()); 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!( "{} Removed '{name}'. The Wine prefix on disk was left untouched.", "✓".green().bold() ); Ok(()) } /// Update global fields non-interactively, then save. /// Use `config edit` for per-launcher changes. pub fn set_globals( &mut self, proton_version: Option, compat_dir: Option, ) -> Result<()> { if proton_version.is_none() && compat_dir.is_none() { anyhow::bail!("nothing to set — pass --proton-version or --compat-dir"); } if let Some(v) = proton_version { self.proton_version = v; } if let Some(d) = compat_dir { self.proton_compat_dir = d; } self.save()?; println!("{} Config saved.", "✓".green().bold()); println!(); self.show() } }