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:
@@ -58,6 +58,8 @@ umutray service install
|
|||||||
| `umutray launchers` | List configured launchers and their state |
|
| `umutray launchers` | List configured launchers and their state |
|
||||||
| `umutray launch <name>` | Launch a specific launcher (e.g. `umutray launch epic`) |
|
| `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 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 diagnose [<name>]` | Health checks (one launcher or all) |
|
||||||
| `umutray setup <name>` | Open the graphical setup wizard for a launcher |
|
| `umutray setup <name>` | Open the graphical setup wizard for a launcher |
|
||||||
| `umutray update-proton --latest` | Install newest GE-Proton release |
|
| `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 set …` | Update globals (`--proton-version`, `--compat-dir`) |
|
||||||
| `umutray config add-launcher …` | Append a new launcher (needs `--exe-path`) |
|
| `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 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 install` | Write + enable a `systemd --user` unit |
|
||||||
| `umutray service uninstall` | Stop, disable, and remove the unit |
|
| `umutray service uninstall` | Stop, disable, and remove the unit |
|
||||||
| `umutray service status` | `systemctl --user status umutray.service` |
|
| `umutray service status` | `systemctl --user status umutray.service` |
|
||||||
|
|||||||
+152
@@ -24,6 +24,10 @@ pub struct Launcher {
|
|||||||
/// Config::proton_version).
|
/// Config::proton_version).
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub proton_version: Option<String>,
|
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 {
|
impl Launcher {
|
||||||
@@ -31,6 +35,40 @@ impl Launcher {
|
|||||||
pub fn full_exe_path(&self) -> PathBuf {
|
pub fn full_exe_path(&self) -> PathBuf {
|
||||||
self.prefix_dir.join("drive_c").join(&self.exe_path)
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -93,6 +131,7 @@ pub fn presets() -> Vec<Launcher> {
|
|||||||
process_pattern: r"Battle\.net".into(),
|
process_pattern: r"Battle\.net".into(),
|
||||||
installer_url: None,
|
installer_url: None,
|
||||||
proton_version: None,
|
proton_version: None,
|
||||||
|
games: vec![],
|
||||||
},
|
},
|
||||||
Launcher {
|
Launcher {
|
||||||
name: "eaapp".into(),
|
name: "eaapp".into(),
|
||||||
@@ -105,6 +144,7 @@ pub fn presets() -> Vec<Launcher> {
|
|||||||
process_pattern: r"EADesktop\.exe".into(),
|
process_pattern: r"EADesktop\.exe".into(),
|
||||||
installer_url: None,
|
installer_url: None,
|
||||||
proton_version: None,
|
proton_version: None,
|
||||||
|
games: vec![],
|
||||||
},
|
},
|
||||||
Launcher {
|
Launcher {
|
||||||
name: "epic".into(),
|
name: "epic".into(),
|
||||||
@@ -117,6 +157,7 @@ pub fn presets() -> Vec<Launcher> {
|
|||||||
process_pattern: r"EpicGamesLauncher\.exe".into(),
|
process_pattern: r"EpicGamesLauncher\.exe".into(),
|
||||||
installer_url: None,
|
installer_url: None,
|
||||||
proton_version: None,
|
proton_version: None,
|
||||||
|
games: vec![],
|
||||||
},
|
},
|
||||||
Launcher {
|
Launcher {
|
||||||
name: "ubisoft".into(),
|
name: "ubisoft".into(),
|
||||||
@@ -129,6 +170,7 @@ pub fn presets() -> Vec<Launcher> {
|
|||||||
process_pattern: r"UbisoftConnect\.exe|upc\.exe".into(),
|
process_pattern: r"UbisoftConnect\.exe|upc\.exe".into(),
|
||||||
installer_url: None,
|
installer_url: None,
|
||||||
proton_version: None,
|
proton_version: None,
|
||||||
|
games: vec![],
|
||||||
},
|
},
|
||||||
Launcher {
|
Launcher {
|
||||||
name: "gog".into(),
|
name: "gog".into(),
|
||||||
@@ -141,6 +183,7 @@ pub fn presets() -> Vec<Launcher> {
|
|||||||
process_pattern: r"GalaxyClient\.exe".into(),
|
process_pattern: r"GalaxyClient\.exe".into(),
|
||||||
installer_url: None,
|
installer_url: None,
|
||||||
proton_version: None,
|
proton_version: None,
|
||||||
|
games: vec![],
|
||||||
},
|
},
|
||||||
Launcher {
|
Launcher {
|
||||||
name: "rockstar".into(),
|
name: "rockstar".into(),
|
||||||
@@ -153,6 +196,7 @@ pub fn presets() -> Vec<Launcher> {
|
|||||||
process_pattern: r"Rockstar Games.*Launcher\.exe".into(),
|
process_pattern: r"Rockstar Games.*Launcher\.exe".into(),
|
||||||
installer_url: None,
|
installer_url: None,
|
||||||
proton_version: None,
|
proton_version: None,
|
||||||
|
games: vec![],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -280,12 +324,120 @@ impl Config {
|
|||||||
process_pattern,
|
process_pattern,
|
||||||
installer_url,
|
installer_url,
|
||||||
proton_version: None,
|
proton_version: None,
|
||||||
|
games: vec![],
|
||||||
});
|
});
|
||||||
self.save()?;
|
self.save()?;
|
||||||
println!("\x1b[1;32m✓\x1b[0m Added launcher '{name}'.");
|
println!("\x1b[1;32m✓\x1b[0m Added launcher '{name}'.");
|
||||||
Ok(())
|
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<()> {
|
pub fn remove_launcher(&mut self, name: &str) -> Result<()> {
|
||||||
let before = self.launchers.len();
|
let before = self.launchers.len();
|
||||||
self.launchers.retain(|l| l.name != name);
|
self.launchers.retain(|l| l.name != name);
|
||||||
|
|||||||
+70
-1
@@ -1,5 +1,7 @@
|
|||||||
use crate::config::{Config, Launcher};
|
use crate::config::{Config, Game, Launcher};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::path::Path;
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -42,6 +44,73 @@ pub fn launch(config: &Config, launcher: &Launcher) -> Result<()> {
|
|||||||
Ok(())
|
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.
|
/// SIGTERM → wait 3 s → SIGKILL for a single launcher.
|
||||||
pub fn kill(launcher: &Launcher) -> Result<()> {
|
pub fn kill(launcher: &Launcher) -> Result<()> {
|
||||||
kill_pattern(&launcher.process_pattern);
|
kill_pattern(&launcher.process_pattern);
|
||||||
|
|||||||
+170
@@ -46,6 +46,21 @@ enum Commands {
|
|||||||
/// List configured launchers and whether they're installed / running
|
/// List configured launchers and whether they're installed / running
|
||||||
Launchers,
|
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)
|
/// Print setup instructions for a launcher (automated wizard coming soon)
|
||||||
Setup {
|
Setup {
|
||||||
/// Launcher name
|
/// Launcher name
|
||||||
@@ -132,6 +147,60 @@ enum ConfigAction {
|
|||||||
/// Short CLI name
|
/// Short CLI name
|
||||||
name: String,
|
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)]
|
#[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 } => {
|
Commands::Setup { name } => {
|
||||||
let l = config.find(&name).ok_or_else(|| {
|
let l = config.find(&name).ok_or_else(|| {
|
||||||
anyhow::anyhow!("unknown launcher '{name}' — try `umutray launchers`")
|
anyhow::anyhow!("unknown launcher '{name}' — try `umutray launchers`")
|
||||||
@@ -231,6 +334,55 @@ fn main() -> Result<()> {
|
|||||||
let mut c = config;
|
let mut c = config;
|
||||||
c.remove_launcher(&name)?;
|
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 {
|
Commands::Service { action } => match action {
|
||||||
@@ -242,3 +394,21 @@ fn main() -> Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
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
@@ -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 struct UmuTray {
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
/// Per-launcher running state keyed by launcher.name
|
/// Per-launcher running state keyed by launcher.name
|
||||||
@@ -69,12 +114,13 @@ impl ksni::Tray for UmuTray {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if running {
|
if running {
|
||||||
|
let kill_name = name.clone();
|
||||||
items.push(
|
items.push(
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: format!("Kill {display}"),
|
label: format!("Kill {display}"),
|
||||||
icon_name: "process-stop".into(),
|
icon_name: "process-stop".into(),
|
||||||
activate: Box::new(move |this: &mut Self| {
|
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);
|
let _ = launcher::kill(l);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -83,12 +129,13 @@ impl ksni::Tray for UmuTray {
|
|||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
let launch_name = name.clone();
|
||||||
items.push(
|
items.push(
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: format!("Launch {display}"),
|
label: format!("Launch {display}"),
|
||||||
icon_name: "media-playback-start".into(),
|
icon_name: "media-playback-start".into(),
|
||||||
activate: Box::new(move |this: &mut Self| {
|
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) {
|
if let Err(e) = launcher::launch(&this.config, l) {
|
||||||
eprintln!("umutray: launch {} failed: {e}", l.name);
|
eprintln!("umutray: launch {} failed: {e}", l.name);
|
||||||
}
|
}
|
||||||
@@ -99,6 +146,75 @@ impl ksni::Tray for UmuTray {
|
|||||||
.into(),
|
.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);
|
items.push(ksni::MenuItem::Separator);
|
||||||
|
|||||||
Reference in New Issue
Block a user