Files
umutray/src/config.rs
T
funman300 9b7e474e80 refactor: apply CLAUDE.md code quality improvements and add packaging
- Add #![forbid(unsafe_code)] to main.rs (issue #3)
- Replace raw ANSI escape codes with owo-colors crate (issue #2)
- Replace manual HOME path construction with dirs::home_dir() (issue #5)
- Ship umutray.service as a static file; service::install() substitutes
  the binary path at install time instead of generating the unit at runtime
- Add packaging/PKGBUILD following Arch Rust package guidelines
- Add CLAUDE.md tracking refactor tasks
- setup.rs: clean up downloaded temp files on abort/back, save launcher
  to config only after successful install, auto-start download when a
  preset has an installer_url
- util.rs: add pick_folder() using zenity/kdialog subprocesses (no rfd)
- config.rs: populate installer_url for all 6 built-in presets with
  official download URLs
- Document the Option<Option<Vec<String>>> gamescope pattern at main.rs:307

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 19:28:10 -07:00

462 lines
16 KiB
Rust

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<String>,
/// Optional per-launcher Proton version override (falls back to
/// Config::proton_version).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proton_version: Option<String>,
/// 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<Game>,
}
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<String>,
/// 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<Vec<String>>,
}
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<Launcher>,
}
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<Launcher> {
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<PathBuf> {
let dirs = ProjectDirs::from("co.aleshym", "", "umutray")
.context("Could not determine config directory")?;
Ok(dirs.config_dir().join("config.toml"))
}
pub fn load() -> Result<Self> {
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::<Self>(&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<String>,
exe_path: PathBuf,
prefix_dir: Option<PathBuf>,
gameid: Option<String>,
process_pattern: Option<String>,
installer_url: Option<String>,
) -> 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<String>,
exe_path: PathBuf,
gamemode: bool,
mangohud: bool,
gamescope: Option<Vec<String>>,
) -> 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<bool>,
mangohud: Option<bool>,
gamescope: Option<Option<Vec<String>>>,
) -> 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<String>,
compat_dir: Option<PathBuf>,
) -> 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()
}
}