diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bd2fcb6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,22 @@ +## umutray refactor tasks + +Work through these issues identified in code review. Address them one at a time and confirm before moving on. + +### Packaging +- [ ] Remove the Makefile and replace with a proper Arch PKGBUILD following https://wiki.archlinux.org/title/Rust_package_guidelines +- [ ] Create a separate repo for the PKGBUILD (keeps packaging out of source repo and makes it AUR-uploadable). Reference the local repo path in the PKGBUILD so it always builds the latest version without pushing/pulling. + +### Systemd / tray architecture +- [ ] Remove runtime systemd unit file generation from Rust code — unit files should be static files shipped in the AUR package, not generated at runtime by the app. +- [ ] Reconsider the `service install` command — the tray icon should use the StatusNotifierItem/AppIndicator XDG protocol (https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem) rather than a systemd service. + +### Code quality +- [ ] Add `#![forbid(unsafe_code)]` to the top of main.rs to enforce safe Rust project-wide. +- [ ] Replace manual terminal color escape codes (in main.rs and detect.rs) with a crate like `colored` or `owo-colors`. +- [ ] Replace manual home directory path construction in config.rs (~L88) with the `dirs` crate. + +### UX / GUI +- [ ] Fix blocking UI on long-running button actions (launch, kill, download) — use iced Command/async tasks so the UI keeps rendering and shows a loading state. + +### Misc +- [ ] Audit and document or refactor the unclear code at main.rs:307. \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index a076217..c4d0d7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -866,7 +866,7 @@ checksum = "2a76fa97167fa740dcdbfe18e8895601e1bc36525f09b044e00916e717c03a3c" dependencies = [ "dconf_rs", "detect-desktop-environment", - "dirs", + "dirs 4.0.0", "objc", "rust-ini", "web-sys", @@ -945,6 +945,15 @@ dependencies = [ "dirs-sys 0.3.7", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs-sys" version = "0.3.7" @@ -2928,6 +2937,12 @@ dependencies = [ "ttf-parser 0.25.1", ] +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + [[package]] name = "palette" version = "0.7.6" @@ -4407,8 +4422,10 @@ dependencies = [ "anyhow", "clap 4.6.1", "directories", + "dirs 5.0.1", "iced", "ksni", + "owo-colors", "reqwest", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index a05bdc1..df69282 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,12 @@ anyhow = "1" # XDG config / data paths directories = "5" +# Home directory lookup +dirs = "5" + +# Terminal colour output +owo-colors = "4" + # System tray via D-Bus StatusNotifierItem (KDE, GNOME+AppIndicator, Xfce, etc.) ksni = "0.2" diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD new file mode 100644 index 0000000..7b90141 --- /dev/null +++ b/packaging/PKGBUILD @@ -0,0 +1,65 @@ +# Maintainer: funman300 +# +# AUR NOTE: This PKGBUILD is kept in the source repo for convenience during +# development. For AUR submission, move it to a separate repository and update +# the `source` array to point to the upstream git URL instead of a local path. +# +# To build locally: +# cd packaging && makepkg -si +# +# To update for AUR, replace the source line with: +# source=("$pkgname::git+https://git.aleshym.co/funman300/umutray.git#tag=v$pkgver") + +pkgname=umutray +pkgver=0.1.0 +pkgrel=1 +pkgdesc='Tray-based Wine launcher manager for Linux via umu/Proton-GE' +arch=('x86_64') +url='https://git.aleshym.co/funman300/umutray' +license=('MIT') +depends=('umu-launcher') +makedepends=('rust' 'cargo') +optdepends=( + 'zenity: folder picker in setup wizard (GNOME/GTK)' + 'kdialog: folder picker in setup wizard (KDE)' + 'gamemode: per-game GameMode support' + 'mangohud: per-game MangoHud overlay' + 'gamescope: per-game Gamescope compositor' +) + +# Build from the local repository. Change to the upstream URL for AUR. +_localrepo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source=("$pkgname::git+file://${_localrepo}") +sha256sums=('SKIP') + +prepare() { + cd "$pkgname" + export RUSTUP_TOOLCHAIN=stable + cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" +} + +build() { + cd "$pkgname" + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR=target + cargo build --release --locked --offline +} + +package() { + cd "$pkgname" + + # Binary + install -Dm755 target/release/umutray "$pkgdir/usr/bin/umutray" + + # App menu entry (.desktop uses /usr/bin/umutray as the exec path) + install -Dm644 umutray.desktop "$pkgdir/usr/share/applications/umutray.desktop" + sed -i "s|Exec=umutray|Exec=/usr/bin/umutray|" \ + "$pkgdir/usr/share/applications/umutray.desktop" + + # Systemd user service (static file — no runtime generation needed) + install -Dm644 umutray.service \ + "$pkgdir/usr/lib/systemd/user/umutray.service" + + # License + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" +} diff --git a/src/config.rs b/src/config.rs index 1d9bda6..ebd82e6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 { ), 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 { ), 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 { ), 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 { ), 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 { ), 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 { ), 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() } diff --git a/src/detect.rs b/src/detect.rs index 12fe6a9..95c4958 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -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>) 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>) 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>) }; 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() diff --git a/src/diagnose.rs b/src/diagnose.rs index 05bc94c..99c5ce9 100644 --- a/src/diagnose.rs +++ b/src/diagnose.rs @@ -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(()) diff --git a/src/main.rs b/src/main.rs index 4acbbe2..158e7eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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>> 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 { diff --git a/src/setup.rs b/src/setup.rs index c872c32..e9d73e3 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -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 { 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 { }; 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, diff --git a/src/util.rs b/src/util.rs index 85c0fb2..518e8a9 100644 --- a/src/util.rs +++ b/src/util.rs @@ -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 { + 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 +} diff --git a/umutray.service b/umutray.service new file mode 100644 index 0000000..0be8d65 --- /dev/null +++ b/umutray.service @@ -0,0 +1,12 @@ +[Unit] +Description=umutray Wine launcher manager +After=graphical-session.target +PartOf=graphical-session.target + +[Service] +ExecStart=/usr/bin/umutray +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=graphical-session.target