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 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
@@ -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 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)?;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user