use anyhow::{Context, Result}; use directories::ProjectDirs; 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, } 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() } 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: 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 { Self { proton_compat_dir: default_compat_dir(), proton_version: default_proton_version(), launchers: presets(), } } } 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(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()); 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, }); 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( &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!("\x1b[1;32m✓\x1b[0m Config saved."); println!(); self.show() } }