9b7e474e80
- 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>
445 lines
13 KiB
Rust
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(", "))
|
|
}
|
|
}
|