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
+5
View File
@@ -58,6 +58,8 @@ umutray service install
| `umutray launchers` | List configured launchers and their state |
| `umutray launch <name>` | Launch a specific launcher (e.g. `umutray launch epic`) |
| `umutray kill [<name>]` | Kill one launcher, or all if no name is given |
| `umutray play <launcher> <game>` | Play a game with its configured overlays |
| `umutray games [<launcher>]` | List configured games and their overlay flags |
| `umutray diagnose [<name>]` | Health checks (one launcher or all) |
| `umutray setup <name>` | Open the graphical setup wizard for a launcher |
| `umutray update-proton --latest` | Install newest GE-Proton release |
@@ -68,6 +70,9 @@ umutray service install
| `umutray config set …` | Update globals (`--proton-version`, `--compat-dir`) |
| `umutray config add-launcher …` | Append a new launcher (needs `--exe-path`) |
| `umutray config remove-launcher` | Drop a launcher (prefix on disk is left untouched) |
| `umutray config add-game …` | Attach a game to a launcher (needs `--exe-path`) |
| `umutray config remove-game …` | Drop a game from a launcher |
| `umutray config set-game-flags …`| Per-game overlay toggles: gamemode/mangohud/gamescope |
| `umutray service install` | Write + enable a `systemd --user` unit |
| `umutray service uninstall` | Stop, disable, and remove the unit |
| `umutray service status` | `systemctl --user status umutray.service` |
+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);
+70 -1
View File
@@ -1,5 +1,7 @@
use crate::config::{Config, Launcher};
use crate::config::{Config, Game, Launcher};
use anyhow::{bail, Context, Result};
use std::ffi::OsString;
use std::path::Path;
use std::process::Stdio;
use std::thread;
use std::time::Duration;
@@ -42,6 +44,73 @@ pub fn launch(config: &Config, launcher: &Launcher) -> Result<()> {
Ok(())
}
/// Launch a game installed through `launcher`, wrapped in the per-game
/// overlays (gamescope, gamemoderun, MANGOHUD). The launcher itself is
/// never wrapped — only games run through this path pick up overlays.
pub fn play_game(config: &Config, launcher: &Launcher, game: &Game) -> Result<()> {
let exe = game.full_exe_path(launcher);
if !exe.exists() {
bail!(
"game exe not found at {:?}\n\
Check exe_path for '{}/{}' in config, or install the game via the launcher first.",
exe,
launcher.name,
game.name,
);
}
let version = launcher
.proton_version
.as_deref()
.unwrap_or(&config.proton_version);
let proton_path: OsString = if version == "GE-Proton" {
version.to_string().into()
} else {
config.proton_compat_dir.join(version).into_os_string()
};
let (prog, args) = build_wrapped_argv(&exe, game);
let mut cmd = std::process::Command::new(&prog);
cmd.env("WINEPREFIX", &launcher.prefix_dir)
.env("GAMEID", &launcher.gameid)
.env("PROTONPATH", &proton_path);
if game.mangohud {
cmd.env("MANGOHUD", "1");
}
cmd.args(&args);
cmd.spawn().with_context(|| {
format!(
"Failed to spawn '{}'. Is umu-run / gamemoderun / gamescope on PATH?",
prog.to_string_lossy()
)
})?;
Ok(())
}
/// Outermost → innermost: gamescope → gamemoderun → umu-run → exe [args]
fn build_wrapped_argv(exe: &Path, game: &Game) -> (OsString, Vec<OsString>) {
let mut argv: Vec<OsString> = Vec::new();
if let Some(gs_args) = &game.gamescope {
argv.push("gamescope".into());
for a in gs_args {
argv.push(a.into());
}
argv.push("--".into());
}
if game.gamemode {
argv.push("gamemoderun".into());
}
argv.push("umu-run".into());
argv.push(exe.as_os_str().to_os_string());
for a in &game.args {
argv.push(a.into());
}
let mut iter = argv.into_iter();
let prog = iter.next().expect("argv contains at least umu-run");
(prog, iter.collect())
}
/// SIGTERM → wait 3 s → SIGKILL for a single launcher.
pub fn kill(launcher: &Launcher) -> Result<()> {
kill_pattern(&launcher.process_pattern);
+170
View File
@@ -46,6 +46,21 @@ enum Commands {
/// List configured launchers and whether they're installed / running
Launchers,
/// Play a specific game through its launcher's prefix, applying the
/// per-game overlay flags (gamemode, mangohud, gamescope).
Play {
/// Launcher name (e.g. battlenet)
launcher: String,
/// Game name (e.g. overwatch)
game: String,
},
/// List configured games per launcher
Games {
/// Only show games for this launcher (omit for all)
launcher: Option<String>,
},
/// Print setup instructions for a launcher (automated wizard coming soon)
Setup {
/// Launcher name
@@ -132,6 +147,60 @@ enum ConfigAction {
/// Short CLI name
name: String,
},
/// Add a game under an existing launcher
AddGame {
/// Launcher that owns this game
launcher: String,
/// Short CLI name for the game (e.g. "overwatch")
name: String,
/// Game exe path relative to drive_c/
#[arg(long, value_name = "PATH")]
exe_path: PathBuf,
/// Display name (defaults to NAME)
#[arg(long)]
display: Option<String>,
/// Wrap the game in gamemoderun
#[arg(long)]
gamemode: bool,
/// Set MANGOHUD=1 for the game
#[arg(long)]
mangohud: bool,
/// Enable gamescope with these args (space-separated, e.g. "-f -W 2560")
#[arg(long, value_name = "ARGS")]
gamescope: Option<String>,
},
/// Remove a game from a launcher
RemoveGame {
launcher: String,
name: String,
},
/// Toggle per-game overlay flags
SetGameFlags {
launcher: String,
name: String,
/// true / false — wrap in gamemoderun
#[arg(long, value_name = "BOOL")]
gamemode: Option<bool>,
/// true / false — set MANGOHUD=1
#[arg(long, value_name = "BOOL")]
mangohud: Option<bool>,
/// Enable gamescope with these args (space-separated)
#[arg(long, value_name = "ARGS", conflicts_with = "no_gamescope")]
gamescope: Option<String>,
/// Disable gamescope wrapping
#[arg(long)]
no_gamescope: bool,
},
}
#[derive(Subcommand)]
@@ -186,6 +255,40 @@ fn main() -> Result<()> {
}
}
Commands::Play { launcher: lname, game: gname } => {
let l = config.find(&lname).ok_or_else(|| {
anyhow::anyhow!("unknown launcher '{lname}' — try `umutray launchers`")
})?;
let g = l.find_game(&gname).ok_or_else(|| {
anyhow::anyhow!(
"launcher '{lname}' has no game named '{gname}' — try `umutray games {lname}`"
)
})?;
launcher::play_game(&config, l, g)?;
}
Commands::Games { launcher: lname } => {
let launchers: Vec<&config::Launcher> = match &lname {
Some(n) => vec![config.find(n).ok_or_else(|| {
anyhow::anyhow!("unknown launcher '{n}' — try `umutray launchers`")
})?],
None => config.launchers.iter().collect(),
};
for l in launchers {
if l.games.is_empty() {
println!(" {}: (no games)", l.display);
continue;
}
println!(" {}:", l.display);
for g in &l.games {
let installed = g.full_exe_path(l).exists();
let marker = if installed { "\x1b[1;32m✓\x1b[0m" } else { "·" };
let flags = format_game_flags(g);
println!(" {marker} {:14} {}{}", g.name, g.display, flags);
}
}
}
Commands::Setup { name } => {
let l = config.find(&name).ok_or_else(|| {
anyhow::anyhow!("unknown launcher '{name}' — try `umutray launchers`")
@@ -231,6 +334,55 @@ fn main() -> Result<()> {
let mut c = config;
c.remove_launcher(&name)?;
}
ConfigAction::AddGame {
launcher,
name,
exe_path,
display,
gamemode,
mangohud,
gamescope,
} => {
let mut c = config;
let gs = gamescope.map(|s| {
s.split_whitespace().map(String::from).collect::<Vec<_>>()
});
c.add_game(
&launcher,
name,
display,
exe_path,
gamemode,
mangohud,
gs,
)?;
}
ConfigAction::RemoveGame { launcher, name } => {
let mut c = config;
c.remove_game(&launcher, &name)?;
}
ConfigAction::SetGameFlags {
launcher,
name,
gamemode,
mangohud,
gamescope,
no_gamescope,
} => {
let gs_update = if no_gamescope {
Some(None)
} else {
gamescope.map(|s| {
Some(
s.split_whitespace()
.map(String::from)
.collect::<Vec<_>>(),
)
})
};
let mut c = config;
c.set_game_flags(&launcher, &name, gamemode, mangohud, gs_update)?;
}
},
Commands::Service { action } => match action {
@@ -242,3 +394,21 @@ fn main() -> Result<()> {
Ok(())
}
fn format_game_flags(g: &config::Game) -> String {
let mut tags: Vec<&str> = Vec::new();
if g.gamemode {
tags.push("gamemode");
}
if g.mangohud {
tags.push("mangohud");
}
if g.gamescope.is_some() {
tags.push("gamescope");
}
if tags.is_empty() {
String::new()
} else {
format!(" [{}]", tags.join(", "))
}
}
+118 -2
View File
@@ -16,6 +16,51 @@ fn spawn_setup(name: &str) {
}
}
enum GameFlag {
GameMode,
MangoHud,
Gamescope,
}
fn toggle_flag(this: &mut UmuTray, launcher: &str, game: &str, flag: GameFlag) {
for l in this.config.launchers.iter_mut() {
if l.name != launcher {
continue;
}
for g in l.games.iter_mut() {
if g.name != game {
continue;
}
match flag {
GameFlag::GameMode => g.gamemode = !g.gamemode,
GameFlag::MangoHud => g.mangohud = !g.mangohud,
GameFlag::Gamescope => {
g.gamescope = if g.gamescope.is_some() {
None
} else {
Some(vec![])
};
}
}
}
}
if let Err(e) = this.config.save() {
eprintln!("umutray: failed to persist flag toggle: {e}");
}
}
fn play_from_tray(config: &Config, launcher: &str, game: &str) {
let Some(l) = config.find(launcher) else {
return;
};
let Some(g) = l.find_game(game) else {
return;
};
if let Err(e) = crate::launcher::play_game(config, l, g) {
eprintln!("umutray: play {game} failed: {e}");
}
}
pub struct UmuTray {
pub config: Config,
/// Per-launcher running state keyed by launcher.name
@@ -69,12 +114,13 @@ impl ksni::Tray for UmuTray {
}
if running {
let kill_name = name.clone();
items.push(
StandardItem {
label: format!("Kill {display}"),
icon_name: "process-stop".into(),
activate: Box::new(move |this: &mut Self| {
if let Some(l) = this.config.find(&name) {
if let Some(l) = this.config.find(&kill_name) {
let _ = launcher::kill(l);
}
}),
@@ -83,12 +129,13 @@ impl ksni::Tray for UmuTray {
.into(),
);
} else {
let launch_name = name.clone();
items.push(
StandardItem {
label: format!("Launch {display}"),
icon_name: "media-playback-start".into(),
activate: Box::new(move |this: &mut Self| {
if let Some(l) = this.config.find(&name) {
if let Some(l) = this.config.find(&launch_name) {
if let Err(e) = launcher::launch(&this.config, l) {
eprintln!("umutray: launch {} failed: {e}", l.name);
}
@@ -99,6 +146,75 @@ impl ksni::Tray for UmuTray {
.into(),
);
}
// Per-game submenus with Play + overlay toggles.
for g in &l.games {
let gdisplay = g.display.clone();
let gname_play = g.name.clone();
let lname_play = name.clone();
let gname_gm = g.name.clone();
let lname_gm = name.clone();
let gname_mh = g.name.clone();
let lname_mh = name.clone();
let gname_gs = g.name.clone();
let lname_gs = name.clone();
let mut sub: Vec<ksni::MenuItem<Self>> = Vec::new();
sub.push(
StandardItem {
label: format!("Play {gdisplay}"),
icon_name: "media-playback-start".into(),
activate: Box::new(move |this: &mut Self| {
play_from_tray(&this.config, &lname_play, &gname_play);
}),
..Default::default()
}
.into(),
);
sub.push(ksni::MenuItem::Separator);
sub.push(
CheckmarkItem {
label: "GameMode".into(),
checked: g.gamemode,
activate: Box::new(move |this: &mut Self| {
toggle_flag(this, &lname_gm, &gname_gm, GameFlag::GameMode);
}),
..Default::default()
}
.into(),
);
sub.push(
CheckmarkItem {
label: "MangoHud".into(),
checked: g.mangohud,
activate: Box::new(move |this: &mut Self| {
toggle_flag(this, &lname_mh, &gname_mh, GameFlag::MangoHud);
}),
..Default::default()
}
.into(),
);
sub.push(
CheckmarkItem {
label: "Gamescope".into(),
checked: g.gamescope.is_some(),
activate: Box::new(move |this: &mut Self| {
toggle_flag(this, &lname_gs, &gname_gs, GameFlag::Gamescope);
}),
..Default::default()
}
.into(),
);
items.push(
SubMenu {
label: gdisplay,
submenu: sub,
..Default::default()
}
.into(),
);
}
}
items.push(ksni::MenuItem::Separator);