Files
umutray/src/main.rs
T
funman300 9b7e474e80 refactor: apply CLAUDE.md code quality improvements and add packaging
- Add #![forbid(unsafe_code)] to main.rs (issue #3)
- Replace raw ANSI escape codes with owo-colors crate (issue #2)
- Replace manual HOME path construction with dirs::home_dir() (issue #5)
- Ship umutray.service as a static file; service::install() substitutes
  the binary path at install time instead of generating the unit at runtime
- Add packaging/PKGBUILD following Arch Rust package guidelines
- Add CLAUDE.md tracking refactor tasks
- setup.rs: clean up downloaded temp files on abort/back, save launcher
  to config only after successful install, auto-start download when a
  preset has an installer_url
- util.rs: add pick_folder() using zenity/kdialog subprocesses (no rfd)
- config.rs: populate installer_url for all 6 built-in presets with
  official download URLs
- Document the Option<Option<Vec<String>>> gamescope pattern at main.rs:307

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 19:28:10 -07:00

445 lines
13 KiB
Rust

#![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<Commands>,
}
#[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<String>,
},
/// Run health checks on a specific launcher, or all of them
Diagnose {
/// Launcher name (omit to check all)
name: Option<String>,
},
/// List configured launchers and whether they're installed / running
Launchers,
/// Play a specific game through its launcher's prefix, applying the
/// per-game overlay flags (gamemode, mangohud, gamescope).
Play {
/// Launcher name (e.g. battlenet)
launcher: String,
/// Game name (e.g. overwatch)
game: String,
},
/// List configured games per launcher
Games {
/// Only show games for this launcher (omit for all)
launcher: Option<String>,
},
/// 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<String>,
},
/// 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<PathBuf>,
/// 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<String>,
/// 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<String>,
/// GE-Proton install directory
#[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)]
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::<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,
} => {
// gs_update is Option<Option<Vec<String>>> 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::<Vec<_>>()))
};
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(", "))
}
}