Add detect command to find installed launchers on disk

Scans common Wine prefix locations (~/Games, ~/.wine, Lutris, Bottles,
Heroic) plus any user-supplied --dir paths for each configured
launcher's exe. Reports matches with four markers:

  ✓ already configured at that prefix
  → detected at a different prefix (--apply to update)
  ⚠ multiple prefixes match (ambiguous)
  · not found

--apply writes the new prefix_dir back to config.toml for unambiguous
cases; ambiguous ones are skipped with a note to resolve via
`config edit`. The Setup doc comment is also refreshed since the iced
wizard landed in an earlier commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-17 21:33:48 -07:00
parent b72c642223
commit e72ee69c14
3 changed files with 206 additions and 1 deletions
+1
View File
@@ -62,6 +62,7 @@ umutray service install
| `umutray games [<launcher>]` | List configured games and their overlay flags | | `umutray games [<launcher>]` | List configured games and their overlay flags |
| `umutray diagnose [<name>]` | Health checks (one launcher or all) | | `umutray diagnose [<name>]` | Health checks (one launcher or all) |
| `umutray setup <name>` | Open the graphical setup wizard for a launcher | | `umutray setup <name>` | 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 --latest` | Install newest GE-Proton release |
| `umutray update-proton --list` | Show recent releases without installing | | `umutray update-proton --list` | Show recent releases without installing |
| `umutray update-proton` | Interactive version picker | | `umutray update-proton` | Interactive version picker |
+188
View File
@@ -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<PathBuf> = 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<PathBuf> {
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<PathBuf> {
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<PathBuf>) {
if dir.join("drive_c").is_dir() {
out.push(dir.to_path_buf());
return;
}
// Proton / umu layout: <gameid>/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<String, Vec<PathBuf>> {
let mut by_launcher: HashMap<String, Vec<PathBuf>> = 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<String, Vec<PathBuf>>) {
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<String, Vec<PathBuf>>,
) -> 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(())
}
+17 -1
View File
@@ -1,4 +1,5 @@
mod config; mod config;
mod detect;
mod diagnose; mod diagnose;
mod launcher; mod launcher;
mod proton; mod proton;
@@ -61,12 +62,23 @@ enum Commands {
launcher: Option<String>, launcher: Option<String>,
}, },
/// Print setup instructions for a launcher (automated wizard coming soon) /// Open the graphical setup wizard for a launcher
Setup { Setup {
/// Launcher name /// Launcher name
name: String, name: String,
}, },
/// 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 /// Download and switch GE-Proton versions
UpdateProton { UpdateProton {
/// Install the latest release automatically /// Install the latest release automatically
@@ -296,6 +308,10 @@ fn main() -> Result<()> {
setup::run(&config, l)?; setup::run(&config, l)?;
} }
Commands::Detect { dir, apply } => {
detect::run(&config, &dir, apply)?;
}
Commands::UpdateProton { latest, version, list } => { Commands::UpdateProton { latest, version, list } => {
proton::run(&config, latest, version, list)?; proton::run(&config, latest, version, list)?;
} }