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>
This commit is contained in:
funman300
2026-04-18 19:28:10 -07:00
parent f2f584febf
commit 9b7e474e80
11 changed files with 233 additions and 57 deletions
+27 -16
View File
@@ -1,5 +1,6 @@
use anyhow::{Context, Result};
use directories::ProjectDirs;
use owo_colors::OwoColorize;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
@@ -86,9 +87,7 @@ pub struct Config {
}
fn home_dir() -> PathBuf {
std::env::var("HOME")
.map(PathBuf::from)
.expect("$HOME is not set; cannot determine default paths")
dirs::home_dir().expect("Cannot determine home directory")
}
fn default_compat_dir() -> PathBuf {
@@ -128,7 +127,9 @@ pub fn presets() -> Vec<Launcher> {
),
gameid: "umu-battlenet".into(),
process_pattern: r"Battle\.net".into(),
installer_url: None,
installer_url: Some(
"https://www.battle.net/download/getInstallerForGame?os=win&gameProgram=BATTLENET_APP&version=Live".into(),
),
proton_version: None,
games: vec![],
},
@@ -141,7 +142,9 @@ pub fn presets() -> Vec<Launcher> {
),
gameid: "umu-eaapp".into(),
process_pattern: r"EADesktop\.exe".into(),
installer_url: None,
installer_url: Some(
"https://origin-a.akamaihd.net/EA-Desktop-Client-Download/installer-releases/EAappInstaller.exe".into(),
),
proton_version: None,
games: vec![],
},
@@ -154,7 +157,9 @@ pub fn presets() -> Vec<Launcher> {
),
gameid: "umu-epicgameslauncher".into(),
process_pattern: r"EpicGamesLauncher\.exe".into(),
installer_url: None,
installer_url: Some(
"https://launcher-public-service-prod06.ol.epicgames.com/launcher/api/installer/download/EpicGamesLauncherInstaller.msi".into(),
),
proton_version: None,
games: vec![],
},
@@ -167,7 +172,9 @@ pub fn presets() -> Vec<Launcher> {
),
gameid: "umu-uplay".into(),
process_pattern: r"UbisoftConnect\.exe|upc\.exe".into(),
installer_url: None,
installer_url: Some(
"https://ubistatic3-a.akamaihd.net/orbit/launcher_installer/UbisoftConnectInstaller.exe".into(),
),
proton_version: None,
games: vec![],
},
@@ -180,7 +187,9 @@ pub fn presets() -> Vec<Launcher> {
),
gameid: "umu-gog".into(),
process_pattern: r"GalaxyClient\.exe".into(),
installer_url: None,
installer_url: Some(
"https://webinstallers.gog-statics.com/download/GOG_Galaxy_2.0.exe".into(),
),
proton_version: None,
games: vec![],
},
@@ -193,7 +202,9 @@ pub fn presets() -> Vec<Launcher> {
),
gameid: "umu-rockstar".into(),
process_pattern: r"Rockstar Games.*Launcher\.exe".into(),
installer_url: None,
installer_url: Some(
"https://gamedownloads.rockstargames.com/public/installer/Rockstar-Games-Launcher.exe".into(),
),
proton_version: None,
games: vec![],
},
@@ -320,7 +331,7 @@ impl Config {
games: vec![],
});
self.save()?;
println!("\x1b[1;32m✓\x1b[0m Added launcher '{name}'.");
println!("{} Added launcher '{name}'.", "".green().bold());
Ok(())
}
@@ -354,7 +365,7 @@ impl Config {
gamescope,
});
self.save()?;
println!("\x1b[1;32m✓\x1b[0m Added game '{name}' under launcher '{launcher}'.");
println!("{} Added game '{name}' under launcher '{launcher}'.", "".green().bold());
Ok(())
}
@@ -370,7 +381,7 @@ impl Config {
anyhow::bail!("launcher '{launcher}' has no game named '{name}'");
}
self.save()?;
println!("\x1b[1;32m✓\x1b[0m Removed game '{name}' from '{launcher}'.");
println!("{} Removed game '{name}' from '{launcher}'.", "".green().bold());
Ok(())
}
@@ -408,7 +419,7 @@ impl Config {
g.gamescope = v;
}
self.save()?;
println!("\x1b[1;32m✓\x1b[0m Updated flags for '{launcher}/{name}'.");
println!("{} Updated flags for '{launcher}/{name}'.", "".green().bold());
Ok(())
}
@@ -420,8 +431,8 @@ impl Config {
}
self.save()?;
println!(
"\x1b[1;32m✓\x1b[0m Removed '{name}'. \
The Wine prefix on disk was left untouched."
"{} Removed '{name}'. The Wine prefix on disk was left untouched.",
"".green().bold()
);
Ok(())
}
@@ -443,7 +454,7 @@ impl Config {
self.proton_compat_dir = d;
}
self.save()?;
println!("\x1b[1;32m✓\x1b[0m Config saved.");
println!("{} Config saved.", "".green().bold());
println!();
self.show()
}
+9 -9
View File
@@ -1,5 +1,6 @@
use crate::config::Config;
use anyhow::Result;
use owo_colors::OwoColorize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@@ -135,7 +136,7 @@ fn print_findings(config: &Config, by_launcher: &HashMap<String, Vec<PathBuf>>)
println!(" · {:12} not found", l.name);
}
Some(matches) if matches.len() > 1 => {
println!(" \x1b[33m⚠\x1b[0m {:12} multiple prefixes:", l.name);
println!(" {} {:12} multiple prefixes:", "".yellow(), l.name);
for p in matches {
println!(" {}", p.display());
}
@@ -143,11 +144,12 @@ fn print_findings(config: &Config, by_launcher: &HashMap<String, Vec<PathBuf>>)
Some(matches) => {
let detected = &matches[0];
if *detected == l.prefix_dir {
println!(" \x1b[1;32m✓\x1b[0m {:12} {}", l.name, detected.display());
println!(" {} {:12} {}", "".green().bold(), l.name, detected.display());
} else {
any_divergent = true;
println!(
" \x1b[36m→\x1b[0m {:12} {} (was {})",
" {} {:12} {} (was {})",
"".cyan(),
l.name,
detected.display(),
l.prefix_dir.display()
@@ -171,19 +173,17 @@ fn apply_findings(config: &Config, by_launcher: &HashMap<String, Vec<PathBuf>>)
};
if matches.len() > 1 {
ambiguous += 1;
println!(
" \x1b[33m⚠\x1b[0m {:12} ambiguous — update via `config edit`",
l.name
);
println!(" {} {:12} ambiguous — update via `config edit`", "".yellow(), l.name);
continue;
}
let detected = &matches[0];
if *detected == l.prefix_dir {
println!(" \x1b[1;32m✓\x1b[0m {:12} unchanged", l.name);
println!(" {} {:12} unchanged", "".green().bold(), l.name);
continue;
}
println!(
" \x1b[1;32m→\x1b[0m {:12} {}{}",
" {} {:12} {}{}",
"".green().bold(),
l.name,
l.prefix_dir.display(),
detected.display()
+7 -7
View File
@@ -1,5 +1,6 @@
use crate::config::{Config, Launcher};
use anyhow::Result;
use owo_colors::OwoColorize;
use std::os::unix::fs::MetadataExt;
use std::path::Path;
use std::process::{Command, Stdio};
@@ -50,18 +51,17 @@ pub fn run(config: &Config, name: Option<&str>) -> Result<()> {
if !c.pass {
issues += 1;
}
let (sym, col, rst) = if c.pass {
("", "\x1b[1;32m", "\x1b[0m")
if c.pass {
println!(" {} {:24} {}", "".green().bold(), c.label, c.detail);
} else {
("", "\x1b[1;31m", "\x1b[0m")
};
println!(" {col}{sym}{rst} {:24} {}", c.label, c.detail);
println!(" {} {:24} {}", "".red().bold(), c.label, c.detail);
}
}
println!();
if issues == 0 {
println!(" \x1b[1;32mAll checks passed.\x1b[0m");
println!(" {}", "All checks passed.".green().bold());
} else {
println!(" \x1b[1;31m{issues} issue(s) found — see ✗ items above.\x1b[0m");
println!(" {}", format!("{issues} issue(s) found — see ✗ items above.").red().bold());
}
println!();
Ok(())
+14 -15
View File
@@ -1,3 +1,5 @@
#![forbid(unsafe_code)]
mod config;
mod detect;
mod diagnose;
@@ -11,6 +13,7 @@ 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.
@@ -105,7 +108,7 @@ enum Commands {
action: ConfigAction,
},
/// Manage the systemd --user service that autostarts the tray
/// Manage the XDG autostart entry that starts the tray on login
Service {
#[command(subcommand)]
action: ServiceAction,
@@ -219,13 +222,13 @@ enum ConfigAction {
#[derive(Subcommand)]
enum ServiceAction {
/// Write the unit, daemon-reload, and enable+start the service (includes app menu entry)
/// Write ~/.config/autostart/umutray.desktop so the tray starts on login (includes app menu entry)
Install,
/// Stop, disable, and remove the unit file (includes app menu entry)
/// Remove the autostart entry and app menu entry
Uninstall,
/// Show `systemctl --user status` for the service
/// Show whether the XDG autostart entry is present
Status,
/// Install only the app menu entry — no systemd service required
/// Install only the app menu entry
InstallDesktop,
/// Remove the app menu entry
UninstallDesktop,
@@ -263,11 +266,7 @@ fn main() -> Result<()> {
for l in &config.launchers {
let installed = l.full_exe_path().exists();
let running = launcher::is_running(l);
let marker = if installed {
"\x1b[1;32m✓\x1b[0m"
} else {
"·"
};
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);
}
@@ -303,11 +302,7 @@ fn main() -> Result<()> {
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 marker = if installed { "".green().bold().to_string() } else { "·".to_string() };
let flags = format_game_flags(g);
println!(" {marker} {:14} {}{}", g.name, g.display, flags);
}
@@ -403,6 +398,10 @@ fn main() -> Result<()> {
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 {
+33 -9
View File
@@ -1,5 +1,4 @@
use crate::{config::{self, Config, Launcher}, util::async_blocking};
use rfd;
use crate::{config::{self, Config, Launcher}, util::{async_blocking, pick_folder}};
use anyhow::Result;
use iced::widget::{
button, column, container, pick_list, progress_bar, row, scrollable, text, text_input, Column,
@@ -141,13 +140,7 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
Task::none()
}
Message::BrowsePrefix => Task::perform(
async {
rfd::AsyncFileDialog::new()
.set_title("Choose install location (Wine prefix)")
.pick_folder()
.await
.map(|h| h.path().to_string_lossy().into_owned())
},
async_blocking(|| pick_folder("Choose install location (Wine prefix)")),
Message::BrowsePrefixDone,
),
Message::BrowsePrefixDone(path) => {
@@ -174,6 +167,28 @@ fn update(state: &mut State, message: Message) -> Task<Message> {
};
preset.prefix_dir = PathBuf::from(&prefix);
// If we know the official installer URL, pre-fill source and
// kick off the download automatically so the user doesn't have
// to find or paste anything.
if let Some(url) = preset.installer_url.clone() {
state.source = url.clone();
state.stage = Stage::Busy;
state.status = format!(
"Found official installer — downloading to {}",
preset.prefix_dir.display()
);
if let Ok(mut p) = state.download.lock() {
*p = DownloadProgress::default();
}
let name = preset.name.clone();
let progress = state.download.clone();
state.launcher = Some(preset);
return Task::perform(
async_blocking(move || download_blocking(&url, &name, progress)),
Message::PrepareDone,
);
}
state.status = format!(
"Paste an installer URL or a local .exe path. It will install into {}.",
preset.prefix_dir.display()
@@ -398,6 +413,14 @@ fn view_install(state: &State) -> Element<'_, Message> {
))
.size(13);
let source_label = if launcher.installer_url.is_some()
&& state.source == launcher.installer_url.as_deref().unwrap_or("")
{
"Official installer URL (auto-filled — or paste your own):"
} else {
"Installer URL or local .exe path:"
};
let input = text_input("https://… or /path/to/installer.exe", &state.source)
.on_input(Message::SourceChanged)
.padding(8);
@@ -454,6 +477,7 @@ fn view_install(state: &State) -> Element<'_, Message> {
header,
prefix,
expected,
text(source_label).size(12),
input,
row![prepare_btn, install_btn].spacing(12),
progress_row,
+20
View File
@@ -14,3 +14,23 @@ where
});
rx.await.expect("blocking task panicked")
}
/// Open a native folder picker dialog and return the chosen path, or None if
/// the user cancelled. Tries zenity (GNOME/GTK) then kdialog (KDE) in order.
pub fn pick_folder(title: &str) -> Option<String> {
for (cmd, args) in [
("zenity", vec!["--file-selection", "--directory", "--title", title]),
("kdialog", vec!["--getexistingdirectory", "/home", "--title", title]),
] {
let Ok(out) = std::process::Command::new(cmd).args(&args).output() else {
continue;
};
if out.status.success() {
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !s.is_empty() {
return Some(s);
}
}
}
None
}