Compare commits

..

5 Commits

Author SHA1 Message Date
funman300 1bacf345f0 Apply rustfmt pass across all modules
Pure whitespace normalization — no logic changes. Mostly:
- collapsing multi-line match/if arms rustfmt prefers inline
- inlining short `with_context`/`ok_or_else` closures
- reformatting nested method chains for consistency

Build and clippy stay clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 21:35:27 -07:00
funman300 e72ee69c14 Add detect command to find installed launchers on disk
Scans common Wine prefix locations (~/Games, ~/.wine, Lutris, Bottles,
Heroic) plus any user-supplied --dir paths for each configured
launcher's exe. Reports matches with four markers:

  ✓ already configured at that prefix
  → detected at a different prefix (--apply to update)
  ⚠ multiple prefixes match (ambiguous)
  · not found

--apply writes the new prefix_dir back to config.toml for unambiguous
cases; ambiguous ones are skipped with a note to resolve via
`config edit`. The Setup doc comment is also refreshed since the iced
wizard landed in an earlier commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 21:33:48 -07:00
funman300 b72c642223 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>
2026-04-17 17:24:55 -07:00
funman300 22fa1efabf Add setup tray entry, wizard progress/log, config add/remove, stale-wine check
- tray: uninstalled launchers now show a "Setup…" entry that spawns
  the setup wizard as a child process.
- setup.rs: download shows a progress bar (bytes / total), and
  umu-run stdout+stderr stream into a scrollable log pane. A 250 ms
  tick subscription pulls updates from the shared state.
- config add-launcher / remove-launcher CLI, with sensible defaults
  for prefix_dir, gameid, and process_pattern derived from name/exe.
- diagnose: flag stale wineserver processes when no launcher is
  running, suggesting `umutray kill`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 12:46:47 -07:00
funman300 14eccf4ef0 Replace setup CLI stub with iced-based wizard
Lets the user paste an installer URL or local .exe path, downloads to
a temp file if needed, and runs the installer via umu-run with the
launcher's prefix, gameid, and proton path wired up. On completion,
checks for the expected exe and reports next steps.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 12:39:09 -07:00
12 changed files with 4830 additions and 115 deletions
Generated
+3553 -27
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -35,3 +35,6 @@ ksni = "0.2"
# HTTP for GE-Proton GitHub releases API
reqwest = { version = "0.12", features = ["blocking", "json"] }
# GUI for the setup wizard
iced = { version = "0.13", features = ["tokio"] }
+13 -3
View File
@@ -26,8 +26,10 @@ Launch / Kill entries. Users can add or remove launchers in `config edit`.
prefix / exe / ownership / running state.
- `service` — installs a `systemd --user` unit so the tray autostarts with
the graphical session.
- `setup`prints the manual setup steps for a launcher (a graphical
wizard via iced is planned).
- `setup`graphical wizard (iced) that downloads an installer URL
(with progress bar) or accepts a local `.exe`, then runs it via
`umu-run` in the launcher's Wine prefix with a live log pane.
Uninstalled launchers expose a **Setup…** entry directly in the tray.
## Install
@@ -56,14 +58,22 @@ 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>` | Print setup steps for a launcher |
| `umutray setup <name>` | Open the graphical setup wizard for a launcher |
| `umutray detect [--apply]` | Scan common Wine prefixes for installed launchers |
| `umutray update-proton --latest` | Install newest GE-Proton release |
| `umutray update-proton --list` | Show recent releases without installing |
| `umutray update-proton` | Interactive version picker |
| `umutray config show` / `path` | Print current config or its file path |
| `umutray config edit` | Open config in `$EDITOR` |
| `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` |
+203 -6
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)]
@@ -61,6 +99,20 @@ fn default_proton_version() -> String {
"GE-Proton".into()
}
fn regex_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 4);
for c in s.chars() {
if matches!(
c,
'.' | '*' | '?' | '+' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' | '^' | '$'
) {
out.push('\\');
}
out.push(c);
}
out
}
/// The six launchers umutray ships out of the box. `exe_path`, `gameid`,
/// and `process_pattern` are best-effort defaults for typical installs —
/// users can adjust per-launcher via `umutray config edit`.
@@ -78,6 +130,7 @@ pub fn presets() -> Vec<Launcher> {
process_pattern: r"Battle\.net".into(),
installer_url: None,
proton_version: None,
games: vec![],
},
Launcher {
name: "eaapp".into(),
@@ -90,6 +143,7 @@ pub fn presets() -> Vec<Launcher> {
process_pattern: r"EADesktop\.exe".into(),
installer_url: None,
proton_version: None,
games: vec![],
},
Launcher {
name: "epic".into(),
@@ -102,6 +156,7 @@ pub fn presets() -> Vec<Launcher> {
process_pattern: r"EpicGamesLauncher\.exe".into(),
installer_url: None,
proton_version: None,
games: vec![],
},
Launcher {
name: "ubisoft".into(),
@@ -114,6 +169,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(),
@@ -126,6 +182,7 @@ pub fn presets() -> Vec<Launcher> {
process_pattern: r"GalaxyClient\.exe".into(),
installer_url: None,
proton_version: None,
games: vec![],
},
Launcher {
name: "rockstar".into(),
@@ -138,6 +195,7 @@ pub fn presets() -> Vec<Launcher> {
process_pattern: r"Rockstar Games.*Launcher\.exe".into(),
installer_url: None,
proton_version: None,
games: vec![],
},
]
}
@@ -178,9 +236,8 @@ impl Config {
}
Err(e) => {
let bak = path.with_extension("toml.bak");
std::fs::rename(&path, &bak).with_context(|| {
format!("Failed to back up stale config to {bak:?}")
})?;
std::fs::rename(&path, &bak)
.with_context(|| format!("Failed to back up stale config to {bak:?}"))?;
eprintln!("warning: couldn't parse {}: {e}", path.display());
eprintln!(
" backed up to {} — writing fresh config with presets",
@@ -231,6 +288,148 @@ impl Config {
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn add_launcher(
&mut self,
name: String,
display: Option<String>,
exe_path: PathBuf,
prefix_dir: Option<PathBuf>,
gameid: Option<String>,
process_pattern: Option<String>,
installer_url: Option<String>,
) -> Result<()> {
if self.launchers.iter().any(|l| l.name == name) {
anyhow::bail!("launcher '{name}' already exists");
}
let display = display.unwrap_or_else(|| name.clone());
let prefix_dir = prefix_dir.unwrap_or_else(|| home_dir().join("Games").join(&name));
let gameid = gameid.unwrap_or_else(|| format!("umu-{name}"));
let process_pattern = process_pattern.unwrap_or_else(|| {
exe_path
.file_name()
.and_then(|s| s.to_str())
.map(regex_escape)
.unwrap_or_else(|| name.clone())
});
self.launchers.push(Launcher {
name: name.clone(),
display,
prefix_dir,
exe_path,
gameid,
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);
if self.launchers.len() == before {
anyhow::bail!("no launcher named '{name}'");
}
self.save()?;
println!(
"\x1b[1;32m✓\x1b[0m Removed '{name}'. \
The Wine prefix on disk was left untouched."
);
Ok(())
}
/// Update global fields non-interactively, then save.
/// Use `config edit` for per-launcher changes.
pub fn set_globals(
@@ -239,9 +438,7 @@ impl Config {
compat_dir: Option<PathBuf>,
) -> Result<()> {
if proton_version.is_none() && compat_dir.is_none() {
anyhow::bail!(
"nothing to set — pass --proton-version or --compat-dir"
);
anyhow::bail!("nothing to set — pass --proton-version or --compat-dir");
}
if let Some(v) = proton_version {
self.proton_version = v;
+178
View File
@@ -0,0 +1,178 @@
use crate::config::Config;
use anyhow::Result;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
const MAX_DEPTH: u32 = 3;
pub fn run(config: &Config, extra_dirs: &[PathBuf], apply: bool) -> Result<()> {
let mut roots = default_roots();
roots.extend(extra_dirs.iter().cloned());
roots.sort();
roots.dedup();
let existing: Vec<PathBuf> = roots.into_iter().filter(|r| r.is_dir()).collect();
let prefixes = scan_prefixes(&existing);
println!(
"Scanned {} root{} → found {} prefix{}.\n",
existing.len(),
if existing.len() == 1 { "" } else { "s" },
prefixes.len(),
if prefixes.len() == 1 { "" } else { "es" },
);
let by_launcher = match_launchers(config, &prefixes);
if apply {
apply_findings(config, &by_launcher)?;
} else {
print_findings(config, &by_launcher);
}
Ok(())
}
fn default_roots() -> Vec<PathBuf> {
let Ok(home) = std::env::var("HOME").map(PathBuf::from) else {
return Vec::new();
};
vec![
home.join("Games"),
home.join(".wine"),
home.join(".local/share/lutris/runners/wine"),
home.join(".local/share/bottles/bottles"),
home.join(".var/app/com.usebottles.bottles/data/bottles/bottles"),
home.join("Games/Heroic/Prefixes/default"),
home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic/Prefixes/default"),
]
}
fn scan_prefixes(roots: &[PathBuf]) -> Vec<PathBuf> {
let mut out = Vec::new();
for root in roots {
collect_prefixes(root, 0, &mut out);
}
out.sort();
out.dedup();
out
}
fn collect_prefixes(dir: &Path, depth: u32, out: &mut Vec<PathBuf>) {
if dir.join("drive_c").is_dir() {
out.push(dir.to_path_buf());
return;
}
// Proton / umu layout: <gameid>/pfx/drive_c
if dir.join("pfx/drive_c").is_dir() {
out.push(dir.join("pfx"));
return;
}
if depth >= MAX_DEPTH {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
collect_prefixes(&entry.path(), depth + 1, out);
}
}
}
fn match_launchers(config: &Config, prefixes: &[PathBuf]) -> HashMap<String, Vec<PathBuf>> {
let mut by_launcher: HashMap<String, Vec<PathBuf>> = HashMap::new();
for l in &config.launchers {
for prefix in prefixes {
if prefix.join("drive_c").join(&l.exe_path).exists() {
by_launcher
.entry(l.name.clone())
.or_default()
.push(prefix.clone());
}
}
}
by_launcher
}
fn print_findings(config: &Config, by_launcher: &HashMap<String, Vec<PathBuf>>) {
let mut any_divergent = false;
for l in &config.launchers {
match by_launcher.get(&l.name) {
None => {
println!(" · {:12} not found", l.name);
}
Some(matches) if matches.len() > 1 => {
println!(" \x1b[33m⚠\x1b[0m {:12} multiple prefixes:", l.name);
for p in matches {
println!(" {}", p.display());
}
}
Some(matches) => {
let detected = &matches[0];
if *detected == l.prefix_dir {
println!(" \x1b[1;32m✓\x1b[0m {:12} {}", l.name, detected.display());
} else {
any_divergent = true;
println!(
" \x1b[36m→\x1b[0m {:12} {} (was {})",
l.name,
detected.display(),
l.prefix_dir.display()
);
}
}
}
}
if any_divergent {
println!("\nRerun with --apply to update config.");
}
}
fn apply_findings(config: &Config, by_launcher: &HashMap<String, Vec<PathBuf>>) -> Result<()> {
let mut c = config.clone();
let mut updated = 0;
let mut ambiguous = 0;
for l in c.launchers.iter_mut() {
let Some(matches) = by_launcher.get(&l.name) else {
continue;
};
if matches.len() > 1 {
ambiguous += 1;
println!(
" \x1b[33m⚠\x1b[0m {:12} ambiguous — update via `config edit`",
l.name
);
continue;
}
let detected = &matches[0];
if *detected == l.prefix_dir {
println!(" \x1b[1;32m✓\x1b[0m {:12} unchanged", l.name);
continue;
}
println!(
" \x1b[1;32m→\x1b[0m {:12} {}{}",
l.name,
l.prefix_dir.display(),
detected.display()
);
l.prefix_dir = detected.clone();
updated += 1;
}
if updated > 0 {
c.save()?;
println!(
"\nUpdated {updated} launcher{}.",
if updated == 1 { "" } else { "s" }
);
} else {
println!("\nNothing to update.");
}
if ambiguous > 0 {
println!(
"{ambiguous} launcher{} skipped (multiple matches).",
if ambiguous == 1 { "" } else { "s" }
);
}
Ok(())
}
+45 -7
View File
@@ -12,10 +12,18 @@ struct Check {
impl Check {
fn pass(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self { label: label.into(), pass: true, detail: detail.into() }
Self {
label: label.into(),
pass: true,
detail: detail.into(),
}
}
fn fail(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self { label: label.into(), pass: false, detail: detail.into() }
Self {
label: label.into(),
pass: false,
detail: detail.into(),
}
}
}
@@ -25,6 +33,7 @@ pub fn run(config: &Config, name: Option<&str>) -> Result<()> {
global_vulkan_check(),
global_display_check(),
compat_dir_check(config),
wineserver_check(config),
];
let launchers: Vec<&Launcher> = if let Some(n) = name {
@@ -80,7 +89,10 @@ fn global_vulkan_check() -> Check {
if ok {
Check::pass("vulkan", "vulkaninfo OK")
} else {
Check::fail("vulkan", "vulkaninfo failed — check GPU drivers / vulkan-tools")
Check::fail(
"vulkan",
"vulkaninfo failed — check GPU drivers / vulkan-tools",
)
}
}
@@ -124,6 +136,35 @@ fn compat_dir_check(config: &Config) -> Check {
}
}
fn wineserver_check(config: &Config) -> Check {
let count = wineserver_count();
if count == 0 {
return Check::pass("wine procs", "no wineserver running");
}
let any_running = config.launchers.iter().any(crate::launcher::is_running);
if any_running {
Check::pass(
"wine procs",
format!("{count} wineserver process(es); launcher active"),
)
} else {
Check::fail(
"wine procs",
format!("{count} stale wineserver process(es) — try: umutray kill"),
)
}
}
fn wineserver_count() -> usize {
Command::new("pgrep")
.args(["-c", "-f", "wineserver"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.trim().parse::<usize>().ok())
.unwrap_or(0)
}
fn launcher_checks(l: &Launcher) -> Vec<Check> {
let mut out = Vec::new();
let tag = format!("[{}]", l.name);
@@ -155,10 +196,7 @@ fn launcher_checks(l: &Launcher) -> Vec<Check> {
}
if is_owned_by_current_user(&l.prefix_dir) {
out.push(Check::pass(
format!("{tag} owner"),
"owned by current user",
));
out.push(Check::pass(format!("{tag} owner"), "owned by current user"));
} else {
out.push(Check::fail(
format!("{tag} owner"),
+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);
+244 -3
View File
@@ -1,4 +1,5 @@
mod config;
mod detect;
mod diagnose;
mod launcher;
mod proton;
@@ -46,12 +47,38 @@ enum Commands {
/// List configured launchers and whether they're installed / running
Launchers,
/// Print setup instructions for a launcher (automated wizard coming soon)
/// 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>,
},
/// Open the graphical setup wizard for a launcher
Setup {
/// Launcher name
name: String,
},
/// Scan common Wine prefix locations for installed launchers
Detect {
/// Additional directory to scan (repeatable)
#[arg(long, value_name = "PATH")]
dir: Vec<PathBuf>,
/// Write detected prefix_dirs to config
#[arg(long)]
apply: bool,
},
/// Download and switch GE-Proton versions
UpdateProton {
/// Install the latest release automatically
@@ -98,6 +125,91 @@ enum ConfigAction {
#[arg(long, value_name = "PATH")]
compat_dir: Option<PathBuf>,
},
/// Add a new launcher to the config
AddLauncher {
/// Short CLI name (e.g. "heroic")
name: String,
/// Windows exe path relative to drive_c/ (e.g. "Program Files/Foo/foo.exe")
#[arg(long, value_name = "PATH")]
exe_path: PathBuf,
/// Display name for menus (defaults to NAME)
#[arg(long)]
display: Option<String>,
/// Wine prefix dir (defaults to ~/Games/NAME)
#[arg(long, value_name = "PATH")]
prefix_dir: Option<PathBuf>,
/// umu GAMEID (defaults to "umu-NAME")
#[arg(long)]
gameid: Option<String>,
/// pgrep -f regex (defaults to escaped exe basename)
#[arg(long)]
process_pattern: Option<String>,
/// Optional installer URL
#[arg(long)]
installer_url: Option<String>,
},
/// Remove a launcher from the config (leaves its prefix on disk)
RemoveLauncher {
/// 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)]
@@ -152,6 +264,47 @@ 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`")
@@ -159,7 +312,15 @@ fn main() -> Result<()> {
setup::run(&config, l)?;
}
Commands::UpdateProton { latest, version, list } => {
Commands::Detect { dir, apply } => {
detect::run(&config, &dir, apply)?;
}
Commands::UpdateProton {
latest,
version,
list,
} => {
proton::run(&config, latest, version, list)?;
}
@@ -169,10 +330,72 @@ fn main() -> Result<()> {
println!("{}", config::Config::config_path()?.display());
}
ConfigAction::Edit => config::Config::edit()?,
ConfigAction::Set { proton_version, compat_dir } => {
ConfigAction::Set {
proton_version,
compat_dir,
} => {
let mut c = config;
c.set_globals(proton_version, compat_dir)?;
}
ConfigAction::AddLauncher {
name,
exe_path,
display,
prefix_dir,
gameid,
process_pattern,
installer_url,
} => {
let mut c = config;
c.add_launcher(
name,
display,
exe_path,
prefix_dir,
gameid,
process_pattern,
installer_url,
)?;
}
ConfigAction::RemoveLauncher { name } => {
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 {
@@ -184,3 +407,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(", "))
}
}
+18 -11
View File
@@ -3,8 +3,7 @@ use anyhow::{Context, Result};
use serde::Deserialize;
use std::io::Write;
const GITHUB_API: &str =
"https://api.github.com/repos/GloriousEggroll/proton-ge-custom/releases";
const GITHUB_API: &str = "https://api.github.com/repos/GloriousEggroll/proton-ge-custom/releases";
#[derive(Deserialize)]
struct Release {
@@ -153,7 +152,11 @@ fn print_list(config: &Config) -> Result<()> {
let releases = fetch_releases(10)?;
for r in &releases {
let installed = config.proton_compat_dir.join(&r.tag_name).exists();
let marker = if installed { " \x1b[1;32m✓ installed\x1b[0m" } else { "" };
let marker = if installed {
" \x1b[1;32m✓ installed\x1b[0m"
} else {
""
};
println!(" {}{}", r.tag_name, marker);
}
Ok(())
@@ -170,7 +173,12 @@ struct ProgressWriter<W: Write> {
impl<W: Write> ProgressWriter<W> {
fn new(inner: W, total: Option<u64>) -> Self {
Self { inner, total, written: 0, last_print: 0 }
Self {
inner,
total,
written: 0,
last_print: 0,
}
}
fn finish(&mut self) {
@@ -189,12 +197,7 @@ impl<W: Write> Write for ProgressWriter<W> {
match self.total {
Some(t) => {
let pct = (self.written as f64 / t as f64) * 100.0;
eprint!(
"\r {:.1}% ({} / {} MiB)",
pct,
self.written >> 20,
t >> 20,
);
eprint!("\r {:.1}% ({} / {} MiB)", pct, self.written >> 20, t >> 20,);
}
None => eprint!("\r {} MiB", self.written >> 20),
}
@@ -217,7 +220,11 @@ fn pick_interactively(config: &Config) -> Result<String> {
println!("Recent GE-Proton releases:");
for (i, r) in releases.iter().enumerate() {
let installed = config.proton_compat_dir.join(&r.tag_name).exists();
let marker = if installed { " \x1b[1;32m✓\x1b[0m" } else { "" };
let marker = if installed {
" \x1b[1;32m✓\x1b[0m"
} else {
""
};
println!(" {:2}) {}{}", i + 1, r.tag_name, marker);
}
+3 -6
View File
@@ -43,13 +43,11 @@ fn systemctl(args: &[&str]) -> Result<()> {
/// Write the unit, reload systemd, and enable+start the service.
pub fn install() -> Result<()> {
let exe = std::env::current_exe()
.context("Cannot determine path to own executable")?;
let exe = std::env::current_exe().context("Cannot determine path to own executable")?;
let path = unit_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {parent:?}"))?;
std::fs::create_dir_all(parent).with_context(|| format!("Failed to create {parent:?}"))?;
}
let contents = render_unit(&exe);
@@ -77,8 +75,7 @@ pub fn uninstall() -> Result<()> {
let _ = systemctl(&["disable", "--now", UNIT_NAME]);
if path.exists() {
std::fs::remove_file(&path)
.with_context(|| format!("Failed to remove {path:?}"))?;
std::fs::remove_file(&path).with_context(|| format!("Failed to remove {path:?}"))?;
println!("Removed {}", path.display());
} else {
println!("No unit file at {}", path.display());
+363 -46
View File
@@ -1,60 +1,377 @@
use crate::config::{Config, Launcher};
use anyhow::Result;
use iced::futures::channel::oneshot;
use iced::widget::{
button, column, container, progress_bar, row, scrollable, text, text_input, Column,
};
use iced::{Element, Length, Subscription, Task, Theme};
use std::ffi::OsString;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[derive(Debug, Clone)]
pub enum Message {
SourceChanged(String),
PreparePressed,
PrepareDone(Result<PathBuf, String>),
InstallPressed,
InstallDone(Result<i32, String>),
Tick,
}
#[derive(Debug, Clone)]
enum Stage {
Idle,
Busy,
Ready,
Installing,
Finished,
}
#[derive(Debug, Clone, Default)]
struct DownloadProgress {
bytes: u64,
total: Option<u64>,
}
struct State {
config: Config,
launcher: Launcher,
source: String,
installer: Option<PathBuf>,
stage: Stage,
status: String,
download: Arc<Mutex<DownloadProgress>>,
log: Arc<Mutex<Vec<String>>>,
}
impl State {
fn new(config: Config, launcher: Launcher) -> Self {
let status = format!(
"Paste an installer URL or a local .exe path. It will install into {}.",
launcher.prefix_dir.display()
);
Self {
config,
launcher,
source: String::new(),
installer: None,
stage: Stage::Idle,
status,
download: Arc::new(Mutex::new(DownloadProgress::default())),
log: Arc::new(Mutex::new(Vec::new())),
}
}
}
fn update(state: &mut State, message: Message) -> Task<Message> {
match message {
Message::Tick => Task::none(),
Message::SourceChanged(s) => {
state.source = s;
Task::none()
}
Message::PreparePressed => {
let src = state.source.trim().to_string();
if src.is_empty() {
state.status = "Enter an installer URL or local path first.".into();
return Task::none();
}
let path = PathBuf::from(&src);
if path.is_file() {
state.installer = Some(path.clone());
state.stage = Stage::Ready;
state.status = format!("Ready: {}", path.display());
return Task::none();
}
if !src.starts_with("http://") && !src.starts_with("https://") {
state.status = format!("Not a local file and not an http(s) URL: {src}");
return Task::none();
}
state.stage = Stage::Busy;
state.status = format!("Downloading {src}");
if let Ok(mut p) = state.download.lock() {
*p = DownloadProgress::default();
}
let name = state.launcher.name.clone();
let progress = state.download.clone();
Task::perform(
blocking(move || download_blocking(&src, &name, progress)),
Message::PrepareDone,
)
}
Message::PrepareDone(Ok(path)) => {
state.installer = Some(path.clone());
state.stage = Stage::Ready;
state.status = format!("Downloaded to {}", path.display());
Task::none()
}
Message::PrepareDone(Err(e)) => {
state.stage = Stage::Idle;
state.status = format!("Download failed: {e}");
Task::none()
}
Message::InstallPressed => {
let Some(installer) = state.installer.clone() else {
return Task::none();
};
state.stage = Stage::Installing;
state.status = "Running installer via umu-run (this may take several minutes)…".into();
if let Ok(mut v) = state.log.lock() {
v.clear();
}
let config = state.config.clone();
let launcher = state.launcher.clone();
let log = state.log.clone();
Task::perform(
blocking(move || run_installer(&config, &launcher, &installer, log)),
Message::InstallDone,
)
}
Message::InstallDone(res) => {
state.stage = Stage::Finished;
let exe = state.launcher.full_exe_path();
state.status = match res {
Ok(code) if exe.exists() => format!(
"✓ Installer finished (umu exit {code}); {} is present.\n\
You can now run: umutray launch {}",
exe.display(),
state.launcher.name,
),
Ok(code) => format!(
"umu-run exited {code} but the expected exe is not at {}.\n\
Check the installer's destination path, or edit exe_path in config.",
exe.display(),
),
Err(e) => format!("Install failed: {e}"),
};
Task::none()
}
}
}
fn subscription(state: &State) -> Subscription<Message> {
match state.stage {
Stage::Busy | Stage::Installing => {
iced::time::every(Duration::from_millis(250)).map(|_| Message::Tick)
}
_ => Subscription::none(),
}
}
fn view(state: &State) -> Element<'_, Message> {
let header = text(format!("Setup: {}", state.launcher.display)).size(24);
let prefix = text(format!("Prefix: {}", state.launcher.prefix_dir.display())).size(13);
let expected = text(format!(
"Expected: {}",
state.launcher.full_exe_path().display()
))
.size(13);
let input = text_input("https://… or /path/to/installer.exe", &state.source)
.on_input(Message::SourceChanged)
.padding(8);
let prepare_enabled = matches!(state.stage, Stage::Idle | Stage::Ready | Stage::Finished);
let install_enabled = matches!(state.stage, Stage::Ready);
let prepare_btn = button(text("Download / Prepare"))
.on_press_maybe(prepare_enabled.then_some(Message::PreparePressed));
let install_btn = button(text("Run installer"))
.on_press_maybe(install_enabled.then_some(Message::InstallPressed));
let status = text(state.status.clone());
let progress_row: Element<Message> = if matches!(state.stage, Stage::Busy) {
let p = state
.download
.lock()
.map(|p| (p.bytes, p.total))
.unwrap_or((0, None));
let (bytes, total) = p;
let fraction = match total {
Some(t) if t > 0 => (bytes as f32) / (t as f32),
_ => 0.0,
};
let label = match total {
Some(t) => format!("{} / {}", fmt_bytes(bytes), fmt_bytes(t)),
None => format!("{} downloaded", fmt_bytes(bytes)),
};
column![progress_bar(0.0..=1.0, fraction), text(label).size(12)]
.spacing(4)
.into()
} else {
text("").into()
};
let log_pane: Element<Message> = if matches!(state.stage, Stage::Installing | Stage::Finished) {
let lines: Vec<String> = state.log.lock().map(|v| v.clone()).unwrap_or_default();
let tail: Vec<Element<Message>> = lines
.iter()
.rev()
.take(80)
.rev()
.map(|l| text(l.clone()).size(11).into())
.collect();
scrollable(Column::with_children(tail).spacing(2))
.height(Length::Fixed(220.0))
.into()
} else {
text("").into()
};
let body = column![
header,
prefix,
expected,
input,
row![prepare_btn, install_btn].spacing(12),
progress_row,
status,
log_pane,
]
.spacing(12)
.padding(20);
container(body).into()
}
/// Print manual setup steps for a launcher.
///
/// This is a stub until the iced-based setup wizard lands. It walks the
/// user through creating the prefix directory, obtaining the installer,
/// and running it through umu.
pub fn run(config: &Config, launcher: &Launcher) -> Result<()> {
let config = config.clone();
let launcher = launcher.clone();
let title = format!("umutray setup — {}", launcher.display);
iced::application(move |_: &State| title.clone(), update, view)
.subscription(subscription)
.theme(|_| Theme::Dark)
.run_with(move || (State::new(config.clone(), launcher.clone()), Task::none()))
.map_err(|e| anyhow::anyhow!("iced: {e}"))
}
async fn blocking<T, F>(f: F) -> T
where
T: Send + 'static,
F: FnOnce() -> T + Send + 'static,
{
let (tx, rx) = oneshot::channel();
std::thread::spawn(move || {
let _ = tx.send(f());
});
rx.await.expect("setup helper thread panicked")
}
fn download_blocking(
url: &str,
name: &str,
progress: Arc<Mutex<DownloadProgress>>,
) -> Result<PathBuf, String> {
let client = reqwest::blocking::Client::builder()
.user_agent("umutray/0.1")
.build()
.map_err(|e| e.to_string())?;
let mut resp = client
.get(url)
.send()
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let total = resp.content_length();
if let Ok(mut p) = progress.lock() {
p.bytes = 0;
p.total = total;
}
let base = url.split('?').next().unwrap_or(url);
let candidate = base
.rsplit('/')
.next()
.filter(|s| s.contains('.'))
.unwrap_or("");
let filename = if candidate.is_empty() {
format!("{name}-installer.exe")
} else {
candidate.to_string()
};
let tmp = std::env::temp_dir().join(filename);
let mut f = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
let mut buf = [0u8; 64 * 1024];
loop {
let n = resp.read(&mut buf).map_err(|e| e.to_string())?;
if n == 0 {
break;
}
f.write_all(&buf[..n]).map_err(|e| e.to_string())?;
if let Ok(mut p) = progress.lock() {
p.bytes += n as u64;
}
}
Ok(tmp)
}
fn run_installer(
config: &Config,
launcher: &Launcher,
installer: &Path,
log: Arc<Mutex<Vec<String>>>,
) -> Result<i32, String> {
std::fs::create_dir_all(&launcher.prefix_dir).map_err(|e| e.to_string())?;
let version = launcher
.proton_version
.as_deref()
.unwrap_or(&config.proton_version);
let proton_path: String = if version == "GE-Proton" {
version.to_string()
let proton_path: OsString = if version == "GE-Proton" {
version.to_string().into()
} else {
config
.proton_compat_dir
.join(version)
.display()
.to_string()
config.proton_compat_dir.join(version).into_os_string()
};
let mut child = Command::new("umu-run")
.env("WINEPREFIX", &launcher.prefix_dir)
.env("GAMEID", &launcher.gameid)
.env("PROTONPATH", &proton_path)
.arg(installer)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| e.to_string())?;
println!("Setup steps for \x1b[1m{}\x1b[0m ({})", launcher.display, launcher.name);
println!();
println!("1. Create the prefix directory:");
println!(" mkdir -p {}", launcher.prefix_dir.display());
println!();
println!("2. Obtain the Windows installer for {}.", launcher.display);
if let Some(url) = &launcher.installer_url {
println!(" (configured source: {url})");
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let log_out = log.clone();
let log_err = log.clone();
let t_out = std::thread::spawn(move || stream_into(stdout, log_out));
let t_err = std::thread::spawn(move || stream_into(stderr, log_err));
let status = child.wait().map_err(|e| e.to_string())?;
let _ = t_out.join();
let _ = t_err.join();
Ok(status.code().unwrap_or(-1))
}
fn stream_into<R: Read>(r: R, log: Arc<Mutex<Vec<String>>>) {
for line in BufReader::new(r).lines().map_while(Result::ok) {
if let Ok(mut v) = log.lock() {
v.push(line);
if v.len() > 500 {
let drop = v.len() - 500;
v.drain(0..drop);
}
}
}
}
fn fmt_bytes(n: u64) -> String {
const KIB: u64 = 1024;
const MIB: u64 = 1024 * 1024;
const GIB: u64 = 1024 * 1024 * 1024;
if n >= GIB {
format!("{:.2} GiB", n as f64 / GIB as f64)
} else if n >= MIB {
format!("{:.1} MiB", n as f64 / MIB as f64)
} else if n >= KIB {
format!("{:.0} KiB", n as f64 / KIB as f64)
} else {
println!(
" No installer URL is configured for '{}'.",
launcher.name
);
println!(" Download the installer from the vendor and save it locally.");
format!("{n} B")
}
println!();
println!("3. Run the installer under umu (replace INSTALLER.EXE with the path):");
println!(
" WINEPREFIX={} \\",
launcher.prefix_dir.display()
);
println!(" GAMEID={} \\", launcher.gameid);
println!(" PROTONPATH={proton_path} \\");
println!(" umu-run INSTALLER.EXE");
println!();
println!("4. After the installer finishes, verify it placed:");
println!(" {}", launcher.full_exe_path().display());
println!();
println!("5. Then: umutray launch {}", launcher.name);
println!();
println!(
"(A graphical setup wizard via iced is planned — this stub prints the manual\n \
steps in the meantime.)"
);
Ok(())
}
+136 -4
View File
@@ -1,9 +1,66 @@
use crate::{config::Config, launcher};
use anyhow::Result;
use std::collections::HashMap;
use std::path::PathBuf;
use std::thread;
use std::time::Duration;
fn spawn_setup(name: &str) {
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray"));
if let Err(e) = std::process::Command::new(exe)
.arg("setup")
.arg(name)
.spawn()
{
eprintln!("umutray: failed to launch setup for {name}: {e}");
}
}
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
@@ -41,10 +98,14 @@ impl ksni::Tray for UmuTray {
let display = l.display.clone();
if !installed {
let setup_name = name.clone();
items.push(
StandardItem {
label: format!("{display} (not installed)"),
enabled: false,
label: format!("Setup {display}"),
icon_name: "document-new".into(),
activate: Box::new(move |_this: &mut Self| {
spawn_setup(&setup_name);
}),
..Default::default()
}
.into(),
@@ -53,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);
}
}),
@@ -67,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);
}
@@ -83,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);