22fa1efabf
- tray: uninstalled launchers now show a "Setup…" entry that spawns the setup wizard as a child process. - setup.rs: download shows a progress bar (bytes / total), and umu-run stdout+stderr stream into a scrollable log pane. A 250 ms tick subscription pulls updates from the shared state. - config add-launcher / remove-launcher CLI, with sensible defaults for prefix_dir, gameid, and process_pattern derived from name/exe. - diagnose: flag stale wineserver processes when no launcher is running, suggesting `umutray kill`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
327 lines
11 KiB
Rust
327 lines
11 KiB
Rust
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<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>,
|
|
}
|
|
|
|
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<Launcher>,
|
|
}
|
|
|
|
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<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: 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<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(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<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,
|
|
});
|
|
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<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!("\x1b[1;32m✓\x1b[0m Config saved.");
|
|
println!();
|
|
self.show()
|
|
}
|
|
}
|