9b7e474e80
- 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>
462 lines
16 KiB
Rust
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()
|
|
}
|
|
}
|