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:
+152
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user