diff --git a/README.md b/README.md index 715eddd..d984dbd 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ umutray service install | `umutray games []` | List configured games and their overlay flags | | `umutray diagnose []` | Health checks (one launcher or all) | | `umutray setup ` | Open the graphical setup wizard for a launcher | +| `umutray detect [--apply]` | Scan common Wine prefixes for installed launchers | | `umutray update-proton --latest` | Install newest GE-Proton release | | `umutray update-proton --list` | Show recent releases without installing | | `umutray update-proton` | Interactive version picker | diff --git a/src/detect.rs b/src/detect.rs new file mode 100644 index 0000000..98c607f --- /dev/null +++ b/src/detect.rs @@ -0,0 +1,188 @@ +use crate::config::Config; +use anyhow::Result; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +const MAX_DEPTH: u32 = 3; + +pub fn run(config: &Config, extra_dirs: &[PathBuf], apply: bool) -> Result<()> { + let mut roots = default_roots(); + roots.extend(extra_dirs.iter().cloned()); + roots.sort(); + roots.dedup(); + let existing: Vec = roots.into_iter().filter(|r| r.is_dir()).collect(); + + let prefixes = scan_prefixes(&existing); + println!( + "Scanned {} root{} → found {} prefix{}.\n", + existing.len(), + if existing.len() == 1 { "" } else { "s" }, + prefixes.len(), + if prefixes.len() == 1 { "" } else { "es" }, + ); + + let by_launcher = match_launchers(config, &prefixes); + + if apply { + apply_findings(config, &by_launcher)?; + } else { + print_findings(config, &by_launcher); + } + + Ok(()) +} + +fn default_roots() -> Vec { + let Ok(home) = std::env::var("HOME").map(PathBuf::from) else { + return Vec::new(); + }; + vec![ + home.join("Games"), + home.join(".wine"), + home.join(".local/share/lutris/runners/wine"), + home.join(".local/share/bottles/bottles"), + home.join(".var/app/com.usebottles.bottles/data/bottles/bottles"), + home.join("Games/Heroic/Prefixes/default"), + home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic/Prefixes/default"), + ] +} + +fn scan_prefixes(roots: &[PathBuf]) -> Vec { + let mut out = Vec::new(); + for root in roots { + collect_prefixes(root, 0, &mut out); + } + out.sort(); + out.dedup(); + out +} + +fn collect_prefixes(dir: &Path, depth: u32, out: &mut Vec) { + if dir.join("drive_c").is_dir() { + out.push(dir.to_path_buf()); + return; + } + // Proton / umu layout: /pfx/drive_c + if dir.join("pfx/drive_c").is_dir() { + out.push(dir.join("pfx")); + return; + } + if depth >= MAX_DEPTH { + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + collect_prefixes(&entry.path(), depth + 1, out); + } + } +} + +fn match_launchers( + config: &Config, + prefixes: &[PathBuf], +) -> HashMap> { + let mut by_launcher: HashMap> = HashMap::new(); + for l in &config.launchers { + for prefix in prefixes { + if prefix.join("drive_c").join(&l.exe_path).exists() { + by_launcher + .entry(l.name.clone()) + .or_default() + .push(prefix.clone()); + } + } + } + by_launcher +} + +fn print_findings(config: &Config, by_launcher: &HashMap>) { + let mut any_divergent = false; + for l in &config.launchers { + match by_launcher.get(&l.name) { + None => { + println!(" · {:12} not found", l.name); + } + Some(matches) if matches.len() > 1 => { + println!(" \x1b[33m⚠\x1b[0m {:12} multiple prefixes:", l.name); + for p in matches { + println!(" {}", p.display()); + } + } + Some(matches) => { + let detected = &matches[0]; + if *detected == l.prefix_dir { + println!( + " \x1b[1;32m✓\x1b[0m {:12} {}", + l.name, + detected.display() + ); + } else { + any_divergent = true; + println!( + " \x1b[36m→\x1b[0m {:12} {} (was {})", + l.name, + detected.display(), + l.prefix_dir.display() + ); + } + } + } + } + if any_divergent { + println!("\nRerun with --apply to update config."); + } +} + +fn apply_findings( + config: &Config, + by_launcher: &HashMap>, +) -> Result<()> { + let mut c = config.clone(); + let mut updated = 0; + let mut ambiguous = 0; + for l in c.launchers.iter_mut() { + let Some(matches) = by_launcher.get(&l.name) else { + continue; + }; + if matches.len() > 1 { + ambiguous += 1; + println!( + " \x1b[33m⚠\x1b[0m {:12} ambiguous — update via `config edit`", + l.name + ); + continue; + } + let detected = &matches[0]; + if *detected == l.prefix_dir { + println!(" \x1b[1;32m✓\x1b[0m {:12} unchanged", l.name); + continue; + } + println!( + " \x1b[1;32m→\x1b[0m {:12} {} → {}", + l.name, + l.prefix_dir.display(), + detected.display() + ); + l.prefix_dir = detected.clone(); + updated += 1; + } + if updated > 0 { + c.save()?; + println!( + "\nUpdated {updated} launcher{}.", + if updated == 1 { "" } else { "s" } + ); + } else { + println!("\nNothing to update."); + } + if ambiguous > 0 { + println!( + "{ambiguous} launcher{} skipped (multiple matches).", + if ambiguous == 1 { "" } else { "s" } + ); + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 65189c5..b6db57d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod config; +mod detect; mod diagnose; mod launcher; mod proton; @@ -61,12 +62,23 @@ enum Commands { launcher: Option, }, - /// Print setup instructions for a launcher (automated wizard coming soon) + /// Open the graphical setup wizard for a launcher Setup { /// Launcher name name: String, }, + /// 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 @@ -296,6 +308,10 @@ fn main() -> Result<()> { setup::run(&config, l)?; } + Commands::Detect { dir, apply } => { + detect::run(&config, &dir, apply)?; + } + Commands::UpdateProton { latest, version, list } => { proton::run(&config, latest, version, list)?; }