diff --git a/README.md b/README.md index 098b94c..715eddd 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ umutray service install | `umutray launchers` | List configured launchers and their state | | `umutray launch ` | Launch a specific launcher (e.g. `umutray launch epic`) | | `umutray kill []` | Kill one launcher, or all if no name is given | +| `umutray play ` | Play a game with its configured overlays | +| `umutray games []` | List configured games and their overlay flags | | `umutray diagnose []` | Health checks (one launcher or all) | | `umutray setup ` | 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` | diff --git a/src/config.rs b/src/config.rs index d219072..6d59107 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,6 +24,10 @@ pub struct Launcher { /// Config::proton_version). #[serde(default, skip_serializing_if = "Option::is_none")] pub proton_version: Option, + /// 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, } 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, + /// 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>, +} + +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 { 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 { 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 { 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 { 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 { 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 { 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, + exe_path: PathBuf, + gamemode: bool, + mangohud: bool, + gamescope: Option>, + ) -> 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, + mangohud: Option, + gamescope: Option>>, + ) -> 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); diff --git a/src/launcher.rs b/src/launcher.rs index c538361..71fb2cb 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -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) { + let mut argv: Vec = 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); diff --git a/src/main.rs b/src/main.rs index 4356038..65189c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, + }, + /// 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, + + /// 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, + }, + /// 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, + + /// true / false — set MANGOHUD=1 + #[arg(long, value_name = "BOOL")] + mangohud: Option, + + /// Enable gamescope with these args (space-separated) + #[arg(long, value_name = "ARGS", conflicts_with = "no_gamescope")] + gamescope: Option, + + /// 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::>() + }); + 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::>(), + ) + }) + }; + 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(", ")) + } +} diff --git a/src/tray.rs b/src/tray.rs index 1157a58..d320be3 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -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> = 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);