#![forbid(unsafe_code)] mod config; mod detect; mod diagnose; mod gui; mod launcher; mod proton; mod service; mod setup; mod tray; mod util; use anyhow::Result; use clap::{Parser, Subcommand}; use owo_colors::OwoColorize; use std::path::PathBuf; /// Tray-based Wine launcher manager for Linux via umu/Proton-GE. /// /// Running without a subcommand starts the system tray daemon. #[derive(Parser)] #[command(name = "umutray", version, about)] struct Cli { #[command(subcommand)] command: Option, } #[derive(Subcommand)] enum Commands { /// Start the system tray daemon (default when no subcommand given) Tray, /// Launch a configured launcher Launch { /// Launcher name (e.g. battlenet, eaapp, epic) name: String, }, /// Kill a specific launcher, or every configured one if no name is given Kill { /// Launcher name (omit to kill all) name: Option, }, /// Run health checks on a specific launcher, or all of them Diagnose { /// Launcher name (omit to check all) name: Option, }, /// 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, }, /// Open the graphical setup wizard. Omit NAME to pick from the launcher list. Setup { /// Launcher name (e.g. battlenet). Omit to open the launcher picker. name: Option, }, /// Open the graphical dashboard (default when launched from app menu) Gui, /// Scan common Wine prefix locations for installed launchers Detect { /// Additional directory to scan (repeatable) #[arg(long, value_name = "PATH")] dir: Vec, /// Write detected prefix_dirs to config #[arg(long)] apply: bool, }, /// Download and switch GE-Proton versions UpdateProton { /// Install the latest release automatically #[arg(long)] latest: bool, /// Install a specific version (e.g. GE-Proton10-34) #[arg(long, value_name = "VERSION")] version: Option, /// List recent releases and exit #[arg(long)] list: bool, }, /// Show or modify configuration Config { #[command(subcommand)] action: ConfigAction, }, /// Manage the XDG autostart entry that starts the tray on login Service { #[command(subcommand)] action: ServiceAction, }, } #[derive(Subcommand)] enum ConfigAction { /// Print the config file path and current values Show, /// Print just the config file path Path, /// Open the config file in $EDITOR Edit, /// Update global fields. Use `config edit` for per-launcher changes. Set { /// Default Proton version (e.g. GE-Proton, GE-Proton10-34) #[arg(long, value_name = "VERSION")] proton_version: Option, /// GE-Proton install directory #[arg(long, value_name = "PATH")] compat_dir: Option, }, /// 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, /// Wine prefix dir (defaults to ~/Games/NAME) #[arg(long, value_name = "PATH")] prefix_dir: Option, /// umu GAMEID (defaults to "umu-NAME") #[arg(long)] gameid: Option, /// pgrep -f regex (defaults to escaped exe basename) #[arg(long)] process_pattern: Option, /// Optional installer URL #[arg(long)] installer_url: Option, }, /// 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, /// 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)] enum ServiceAction { /// Write ~/.config/autostart/umutray.desktop so the tray starts on login (includes app menu entry) Install, /// Remove the autostart entry and app menu entry Uninstall, /// Show whether the XDG autostart entry is present Status, /// Install only the app menu entry InstallDesktop, /// Remove the app menu entry UninstallDesktop, } fn main() -> Result<()> { let cli = Cli::parse(); let config = config::Config::load()?; match cli.command.unwrap_or(Commands::Tray) { Commands::Tray => tray::run(&config)?, Commands::Launch { name } => { let l = config.find(&name).ok_or_else(|| { anyhow::anyhow!("unknown launcher '{name}' — try `umutray launchers`") })?; launcher::launch(&config, l)?; } Commands::Kill { name } => match name { Some(n) => { let l = config.find(&n).ok_or_else(|| { anyhow::anyhow!("unknown launcher '{n}' — try `umutray launchers`") })?; launcher::kill(l)?; } None => launcher::kill_all(&config)?, }, Commands::Diagnose { name } => { diagnose::run(&config, name.as_deref())?; } Commands::Launchers => { for l in &config.launchers { let installed = l.full_exe_path().exists(); let running = launcher::is_running(l); let marker = if installed { "✓".green().bold().to_string() } else { "·".to_string() }; let state = if running { " (running)" } else { "" }; println!(" {marker} {:12} {}{}", l.name, l.display, state); } } 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 { "✓".green().bold().to_string() } else { "·".to_string() }; let flags = format_game_flags(g); println!(" {marker} {:14} {}{}", g.name, g.display, flags); } } } Commands::Setup { name } => match name { None => setup::run_new(&config)?, Some(n) => { let l = config.find(&n).ok_or_else(|| { anyhow::anyhow!( "unknown launcher '{n}' — try `umutray setup` to add it first" ) })?; setup::run(&config, l)?; } }, Commands::Gui => gui::run(&config)?, Commands::Detect { dir, apply } => { detect::run(&config, &dir, apply)?; } Commands::UpdateProton { latest, version, list, } => { proton::run(&config, latest, version, list)?; } Commands::Config { action } => match action { ConfigAction::Show => config.show()?, ConfigAction::Path => { println!("{}", config::Config::config_path()?.display()); } ConfigAction::Edit => config::Config::edit()?, 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::>()); 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, } => { // gs_update is Option>> where: // None = leave gamescope unchanged // Some(None) = disable gamescope // Some(Some(args)) = enable gamescope with these CLI args 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 { ServiceAction::Install => service::install()?, ServiceAction::Uninstall => service::uninstall()?, ServiceAction::Status => service::status()?, ServiceAction::InstallDesktop => service::install_desktop()?, ServiceAction::UninstallDesktop => service::uninstall_desktop()?, }, } 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(", ")) } }