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:
@@ -62,6 +62,7 @@ umutray service install
|
||||
| `umutray games [<launcher>]` | List configured games and their overlay flags |
|
||||
| `umutray diagnose [<name>]` | Health checks (one launcher or all) |
|
||||
| `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 --list` | Show recent releases without installing |
|
||||
| `umutray update-proton` | Interactive version picker |
|
||||
|
||||
+188
@@ -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
@@ -1,4 +1,5 @@
|
||||
mod config;
|
||||
mod detect;
|
||||
mod diagnose;
|
||||
mod launcher;
|
||||
mod proton;
|
||||
@@ -61,12 +62,23 @@ enum Commands {
|
||||
launcher: Option<String>,
|
||||
},
|
||||
|
||||
/// 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<PathBuf>,
|
||||
|
||||
/// 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)?;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user