Add per-game overlay toggles (gamemode, mangohud, gamescope)

Games live under each Launcher as a Vec<Game>; the launcher itself
never picks up overlays, only games launched through `play` do.

- config: Game struct with gamemode/mangohud/gamescope fields, plus
  add_game / remove_game / set_game_flags methods.
- launcher::play_game wraps the game command as
  `gamescope [args] -- gamemoderun umu-run <exe>` (each layer optional)
  and sets MANGOHUD=1 when enabled.
- CLI: `play`, `games`, `config add-game`, `config remove-game`,
  `config set-game-flags`.
- tray: per-game submenus with Play + checkmark toggles for GameMode /
  MangoHud / Gamescope; toggles persist to disk immediately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-17 17:24:55 -07:00
parent 22fa1efabf
commit b72c642223
5 changed files with 515 additions and 3 deletions
+152
View File
@@ -24,6 +24,10 @@ pub struct Launcher {
/// 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 {
@@ -31,6 +35,40 @@ impl Launcher {
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)]
@@ -93,6 +131,7 @@ pub fn presets() -> Vec<Launcher> {
process_pattern: r"Battle\.net".into(),
installer_url: None,
proton_version: None,
games: vec![],
},
Launcher {
name: "eaapp".into(),
@@ -105,6 +144,7 @@ pub fn presets() -> Vec<Launcher> {
process_pattern: r"EADesktop\.exe".into(),
installer_url: None,
proton_version: None,
games: vec![],
},
Launcher {
name: "epic".into(),
@@ -117,6 +157,7 @@ pub fn presets() -> Vec<Launcher> {
process_pattern: r"EpicGamesLauncher\.exe".into(),
installer_url: None,
proton_version: None,
games: vec![],
},
Launcher {
name: "ubisoft".into(),
@@ -129,6 +170,7 @@ pub fn presets() -> Vec<Launcher> {
process_pattern: r"UbisoftConnect\.exe|upc\.exe".into(),
installer_url: None,
proton_version: None,
games: vec![],
},
Launcher {
name: "gog".into(),
@@ -141,6 +183,7 @@ pub fn presets() -> Vec<Launcher> {
process_pattern: r"GalaxyClient\.exe".into(),
installer_url: None,
proton_version: None,
games: vec![],
},
Launcher {
name: "rockstar".into(),
@@ -153,6 +196,7 @@ pub fn presets() -> Vec<Launcher> {
process_pattern: r"Rockstar Games.*Launcher\.exe".into(),
installer_url: None,
proton_version: None,
games: vec![],
},
]
}
@@ -280,12 +324,120 @@ impl Config {
process_pattern,
installer_url,
proton_version: None,
games: vec![],
});
self.save()?;
println!("\x1b[1;32m✓\x1b[0m Added launcher '{name}'.");
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!(
"\x1b[1;32m✓\x1b[0m Added game '{name}' under launcher '{launcher}'."
);
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!("\x1b[1;32m✓\x1b[0m Removed game '{name}' from '{launcher}'.");
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!(
"\x1b[1;32m✓\x1b[0m Updated flags for '{launcher}/{name}'."
);
Ok(())
}
pub fn remove_launcher(&mut self, name: &str) -> Result<()> {
let before = self.launchers.len();
self.launchers.retain(|l| l.name != name);