2f4f1c64d2
- Replace .map().unwrap_or(false) with .is_some_and()/.is_ok_and()
- Use path.display() instead of {:?} for user-facing messages
- Replace Option<Option<Vec<String>>> with GamescopeUpdate enum
- Replace manual parent-walking loops with .ancestors() iterators
- Simplify kill()/kill_all() signatures to return () instead of Result
- Use tokio::task::spawn_blocking instead of hand-rolled thread+oneshot
- Read /proc/self/status for UID instead of spawning id subprocess
- Build Exec= line directly in render_desktop instead of string-replace
- Bump PKGBUILD pkgrel to 6
618 lines
20 KiB
Rust
618 lines
20 KiB
Rust
use crate::config::{Config, Launcher};
|
||
use anyhow::Result;
|
||
use owo_colors::OwoColorize;
|
||
use std::collections::{HashMap, HashSet};
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::{LazyLock, Mutex};
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct DetectHit {
|
||
pub display: String,
|
||
pub prefix: PathBuf,
|
||
/// True if this launcher is already in config with this exact prefix.
|
||
pub configured: bool,
|
||
}
|
||
|
||
/// Scan default Wine prefix locations and return hits against known presets.
|
||
pub fn scan_for_gui(config: &Config) -> Vec<DetectHit> {
|
||
let roots: Vec<PathBuf> = default_roots().into_iter().filter(|r| r.is_dir()).collect();
|
||
let prefixes = scan_prefixes(&roots);
|
||
let mut hits = Vec::new();
|
||
for preset in crate::config::presets() {
|
||
for prefix in &prefixes {
|
||
if prefix.join("drive_c").join(&preset.exe_path).exists() {
|
||
let configured = config
|
||
.find(&preset.name)
|
||
.is_some_and(|l| l.prefix_dir == *prefix);
|
||
hits.push(DetectHit {
|
||
display: preset.display.clone(),
|
||
prefix: prefix.clone(),
|
||
configured,
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
hits
|
||
}
|
||
|
||
/// Directories inside drive_c that contain Windows system files, not games.
|
||
const SYSTEM_DIRS: &[&str] = &[
|
||
"windows",
|
||
"users",
|
||
"programdata",
|
||
"internet explorer",
|
||
"windows media player",
|
||
"windowspowershell",
|
||
"microsoft.net",
|
||
"common files",
|
||
"microsoft",
|
||
"windows nt",
|
||
"windowsapps",
|
||
];
|
||
|
||
/// Directory names that are pure launcher infrastructure — no game executables
|
||
/// are ever installed here. Do NOT add parent dirs like "Epic Games" or
|
||
/// "Ubisoft" that also contain game subdirectories; use SKIP_EXES instead.
|
||
const SKIP_DIRS: &[&str] = &[
|
||
"battle.net", // Battle.net launcher dir; its games live elsewhere
|
||
"ea desktop", // EA Desktop launcher subfolder only
|
||
"gog galaxy", // GOG Galaxy launcher; games are normally in GOG Games/
|
||
"wine",
|
||
"mono",
|
||
"gecko",
|
||
];
|
||
|
||
/// Exe filename patterns that are launcher tools, not games.
|
||
const SKIP_EXES: &[&str] = &[
|
||
"uninstall",
|
||
"uninst",
|
||
"crash",
|
||
"error",
|
||
"reporter",
|
||
"update",
|
||
"updater",
|
||
"setup",
|
||
"installer",
|
||
"helper",
|
||
"agent",
|
||
"service",
|
||
"repair",
|
||
"diagnostic",
|
||
"redist",
|
||
"vcredist",
|
||
"dxsetup",
|
||
"dxwebsetup",
|
||
"dotnetfx",
|
||
"vc_redist",
|
||
"bootstrapper",
|
||
"launcher", // launcher tools, not games
|
||
"battlenet",
|
||
"blizzard",
|
||
"eadesktop",
|
||
"eabackgroundservice",
|
||
"ealink",
|
||
"epicgameslauncher",
|
||
"epicwebhelper",
|
||
"ubisoftconnect",
|
||
"ubisoftgamelauncher",
|
||
"upc",
|
||
"galaxyclient",
|
||
"galaxycommunication",
|
||
"galaxypeer",
|
||
"socialclubhelper",
|
||
"subprocess",
|
||
"cefprocess",
|
||
"webhelper",
|
||
"webview",
|
||
"7za",
|
||
"aria2c",
|
||
];
|
||
|
||
// --- Name resolution ---
|
||
|
||
/// Cache of absolute exe path → resolved display name (populated lazily).
|
||
static NAME_CACHE: LazyLock<Mutex<HashMap<PathBuf, String>>> =
|
||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||
|
||
/// Install-path → display title, built once from Legendary / Heroic metadata.
|
||
static STORE_TITLES: LazyLock<HashMap<PathBuf, String>> =
|
||
LazyLock::new(build_store_titles);
|
||
|
||
/// Read every `installed.json` that Legendary or Heroic may have written and
|
||
/// return a map of absolute install directory → game title.
|
||
fn build_store_titles() -> HashMap<PathBuf, String> {
|
||
let mut map = HashMap::new();
|
||
let Ok(home) = std::env::var("HOME") else { return map };
|
||
let home = PathBuf::from(home);
|
||
|
||
// Legendary standalone + Heroic's bundled copy (native and Flatpak).
|
||
let legendary_candidates = [
|
||
home.join(".config/legendary/installed.json"),
|
||
home.join(".config/heroic/legendaryConfig/legendary/installed.json"),
|
||
home.join(".var/app/com.heroicgameslauncher.hgl/config/legendary/installed.json"),
|
||
home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic/legendaryConfig/legendary/installed.json"),
|
||
];
|
||
for path in &legendary_candidates {
|
||
if let Ok(text) = std::fs::read_to_string(path) {
|
||
parse_legendary_installed(&text, &mut map);
|
||
}
|
||
}
|
||
|
||
// Heroic GOG store (native and Flatpak).
|
||
let gog_candidates = [
|
||
home.join(".config/heroic/gog_store/installed.json"),
|
||
home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic/gog_store/installed.json"),
|
||
];
|
||
for path in &gog_candidates {
|
||
if let Ok(text) = std::fs::read_to_string(path) {
|
||
parse_heroic_gog_installed(&text, &mut map);
|
||
}
|
||
}
|
||
|
||
map
|
||
}
|
||
|
||
/// Legendary `installed.json`: `{ "AppName": { "install_path": "...", "title": "..." } }`
|
||
fn parse_legendary_installed(text: &str, map: &mut HashMap<PathBuf, String>) {
|
||
let Ok(json) = serde_json::from_str::<serde_json::Value>(text) else { return };
|
||
let Some(obj) = json.as_object() else { return };
|
||
for entry in obj.values() {
|
||
let Some(path) = entry.get("install_path").and_then(|v| v.as_str()) else { continue };
|
||
let Some(title) = entry.get("title").and_then(|v| v.as_str()) else { continue };
|
||
if !title.is_empty() {
|
||
map.insert(PathBuf::from(path), title.to_string());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Heroic GOG `installed.json`: `{ "installed": [ { "install_path": "...", "title": "..." } ] }`
|
||
fn parse_heroic_gog_installed(text: &str, map: &mut HashMap<PathBuf, String>) {
|
||
let Ok(json) = serde_json::from_str::<serde_json::Value>(text) else { return };
|
||
let Some(arr) = json.get("installed").and_then(|v| v.as_array()) else { return };
|
||
for entry in arr {
|
||
let Some(path) = entry.get("install_path").and_then(|v| v.as_str()) else { continue };
|
||
let Some(title) = entry.get("title").and_then(|v| v.as_str()) else { continue };
|
||
if !title.is_empty() {
|
||
map.insert(PathBuf::from(path), title.to_string());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Walk up from `exe_path` checking each ancestor against `STORE_TITLES`.
|
||
fn store_title(exe_path: &Path) -> Option<String> {
|
||
exe_path.ancestors().skip(1).find_map(|d| STORE_TITLES.get(d).cloned())
|
||
}
|
||
|
||
/// Scan a launcher's Wine prefix for installed game executables.
|
||
/// Returns (display_name, path_relative_to_drive_c) pairs, sorted alphabetically,
|
||
/// excluding the launcher's own exe and any already-configured games.
|
||
pub fn scan_games_in_prefix(launcher: &Launcher) -> Vec<(String, String)> {
|
||
let drive_c = launcher.prefix_dir.join("drive_c");
|
||
if !drive_c.exists() {
|
||
return vec![];
|
||
}
|
||
let search_dirs = [
|
||
drive_c.join("Program Files"),
|
||
drive_c.join("Program Files (x86)"),
|
||
];
|
||
let already: HashSet<String> = launcher
|
||
.games
|
||
.iter()
|
||
.map(|g| g.exe_path.to_string_lossy().to_lowercase())
|
||
.collect();
|
||
let launcher_exe = launcher.exe_path.to_string_lossy().to_lowercase();
|
||
|
||
let mut results = Vec::new();
|
||
let mut seen: HashSet<String> = HashSet::new();
|
||
for dir in &search_dirs {
|
||
scan_exe_dir(dir, &drive_c, &launcher_exe, &already, &mut results, &mut seen, 0);
|
||
}
|
||
results.sort_by(|a, b| a.0.cmp(&b.0));
|
||
results
|
||
}
|
||
|
||
fn scan_exe_dir(
|
||
dir: &Path,
|
||
drive_c: &Path,
|
||
launcher_exe: &str,
|
||
already: &HashSet<String>,
|
||
out: &mut Vec<(String, String)>,
|
||
seen: &mut HashSet<String>,
|
||
depth: u32,
|
||
) {
|
||
if depth > 4 {
|
||
return;
|
||
}
|
||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
let lower = path
|
||
.file_name()
|
||
.and_then(|n| n.to_str())
|
||
.unwrap_or("")
|
||
.to_lowercase();
|
||
if SYSTEM_DIRS.iter().any(|s| lower.starts_with(s)) {
|
||
continue;
|
||
}
|
||
if SKIP_DIRS.iter().any(|s| lower == *s) {
|
||
continue;
|
||
}
|
||
if path.is_dir() {
|
||
scan_exe_dir(&path, drive_c, launcher_exe, already, out, seen, depth + 1);
|
||
} else if path
|
||
.extension()
|
||
.and_then(|e| e.to_str())
|
||
.is_some_and(|e| e.eq_ignore_ascii_case("exe"))
|
||
{
|
||
let Ok(rel) = path.strip_prefix(drive_c) else { continue };
|
||
let rel_str = rel.to_string_lossy().to_string();
|
||
let rel_lower = rel_str.to_lowercase();
|
||
if rel_lower == launcher_exe || already.contains(&rel_lower) {
|
||
continue;
|
||
}
|
||
// Skip launcher tools, updaters, and non-game executables
|
||
let stem_lower = path
|
||
.file_stem()
|
||
.and_then(|s| s.to_str())
|
||
.unwrap_or("")
|
||
.to_lowercase();
|
||
if SKIP_EXES.iter().any(|s| stem_lower.contains(s)) {
|
||
continue;
|
||
}
|
||
if !seen.insert(rel_lower) {
|
||
continue;
|
||
}
|
||
let display = resolve_game_name(&path, None);
|
||
out.push((display, rel_str));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Resolve a human-readable display name for a game exe.
|
||
///
|
||
/// Resolution pipeline (first hit wins):
|
||
/// 1. Explicit name — if `explicit_name` is `Some`, return it immediately.
|
||
/// 2. Legendary / Heroic `installed.json` — maps install path → title,
|
||
/// covers both Epic (via Legendary) and GOG (via Heroic's GOG store).
|
||
/// 3. Manifest walk — walks up from the exe looking for GOG `.info` and
|
||
/// Epic `.egstore/*.item` JSON files at the game's installation root.
|
||
/// 4. Launcher path — reads the game name from well-known directory
|
||
/// structures laid down by the launcher (e.g. `Epic Games/<Name>/`).
|
||
/// 5. Nearest non-generic parent directory name, or raw exe stem.
|
||
/// No name generation — if the directory name is unknown, it is used
|
||
/// as-is rather than being fabricated from the exe filename.
|
||
///
|
||
/// Results from stages 2–5 are cached by path after first computation.
|
||
pub fn resolve_game_name(exe_path: &Path, explicit_name: Option<&str>) -> String {
|
||
if let Some(name) = explicit_name {
|
||
return name.to_string();
|
||
}
|
||
|
||
{
|
||
let cache = NAME_CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||
if let Some(cached) = cache.get(exe_path) {
|
||
return cached.clone();
|
||
}
|
||
}
|
||
|
||
let name = resolve_uncached(exe_path);
|
||
|
||
NAME_CACHE
|
||
.lock()
|
||
.unwrap_or_else(|e| e.into_inner())
|
||
.insert(exe_path.to_path_buf(), name.clone());
|
||
|
||
name
|
||
}
|
||
|
||
fn resolve_uncached(exe_path: &Path) -> String {
|
||
// Stage 2 – Legendary / Heroic installed.json (install path → title)
|
||
if let Some(name) = store_title(exe_path) {
|
||
return name;
|
||
}
|
||
|
||
// Stage 3 – manifest files at the game's installation root
|
||
if let Some(name) = read_manifest_name(exe_path) {
|
||
return name;
|
||
}
|
||
|
||
// Stage 4 – game name from known launcher directory structures
|
||
if let Some(name) = name_from_launcher_path(exe_path) {
|
||
return name;
|
||
}
|
||
|
||
// Stage 5 – nearest non-generic parent directory, or raw exe stem.
|
||
// No name generation: if we don't know, we say so honestly.
|
||
nearest_dir_name(exe_path)
|
||
}
|
||
|
||
/// Walk up from `exe_path` looking for platform manifest files that record the
|
||
/// game's display name. Manifests live at the game's installation *root*, which
|
||
/// can be several directories above the actual exe.
|
||
///
|
||
/// Supported formats:
|
||
/// - GOG: `goggame-<id>.info` → `{ "gameName": "..." }`
|
||
/// - Epic: `.egstore/<id>.item` → `{ "DisplayName": "..." }`
|
||
fn read_manifest_name(exe_path: &Path) -> Option<String> {
|
||
for d in exe_path.ancestors().skip(1) {
|
||
let dirname = d.file_name().and_then(|n| n.to_str()).unwrap_or("").to_lowercase();
|
||
// Stop once we reach drive_c root or the Program Files tier — manifests
|
||
// are never above the game's installation folder.
|
||
if dirname == "drive_c" || dirname.starts_with("program files") {
|
||
break;
|
||
}
|
||
if let Some(name) = read_gog_manifest(d).or_else(|| read_epic_manifest(d)) {
|
||
return Some(name);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn read_gog_manifest(dir: &Path) -> Option<String> {
|
||
for entry in std::fs::read_dir(dir).ok()?.flatten() {
|
||
let fname = entry.file_name();
|
||
let fname = fname.to_string_lossy();
|
||
if fname.starts_with("goggame-") && fname.ends_with(".info") {
|
||
let text = std::fs::read_to_string(entry.path()).ok()?;
|
||
let json: serde_json::Value = serde_json::from_str(&text).ok()?;
|
||
let t = json.get("gameName")?.as_str()?.trim();
|
||
if !t.is_empty() {
|
||
return Some(t.to_string());
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn read_epic_manifest(dir: &Path) -> Option<String> {
|
||
let egstore = dir.join(".egstore");
|
||
if !egstore.is_dir() {
|
||
return None;
|
||
}
|
||
for entry in std::fs::read_dir(&egstore).ok()?.flatten() {
|
||
if entry.path().extension().and_then(|e| e.to_str()) == Some("item") {
|
||
let text = std::fs::read_to_string(entry.path()).ok()?;
|
||
let json: serde_json::Value = serde_json::from_str(&text).ok()?;
|
||
let t = json.get("DisplayName")?.as_str()?.trim();
|
||
if !t.is_empty() {
|
||
return Some(t.to_string());
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Extract a game name from well-known launcher directory conventions.
|
||
///
|
||
/// Launchers install each game into a named subdirectory of their own folder.
|
||
/// That subdirectory name *is* the display name:
|
||
/// - Epic: `…/Epic Games/<GameName>/…`
|
||
/// - GOG: `…/GOG Games/<GameName>/…`
|
||
/// - Steam: `…/steamapps/common/<GameName>/…`
|
||
/// - Rockstar:`…/Rockstar Games/<GameName>/…`
|
||
/// - EA: `…/EA Games/<GameName>/…`
|
||
/// - Ubisoft: `…/Ubisoft Game Launcher/games/<GameName>/…`
|
||
fn name_from_launcher_path(exe_path: &Path) -> Option<String> {
|
||
let comps: Vec<&std::ffi::OsStr> = exe_path.components().map(|c| c.as_os_str()).collect();
|
||
|
||
for (i, comp) in comps.iter().enumerate() {
|
||
let lower = comp.to_str().unwrap_or("").to_lowercase();
|
||
match lower.as_str() {
|
||
"epic games" | "gog games" | "rockstar games" | "ea games" => {
|
||
return comps.get(i + 1).and_then(|c| c.to_str()).map(str::to_string);
|
||
}
|
||
// Ubisoft: …/Ubisoft Game Launcher/games/<GameName>/…
|
||
"ubisoft game launcher" => {
|
||
return comps.get(i + 2).and_then(|c| c.to_str()).map(str::to_string);
|
||
}
|
||
"common"
|
||
if i > 0
|
||
&& comps[i - 1].to_str().unwrap_or("").to_lowercase() == "steamapps" =>
|
||
{
|
||
return comps.get(i + 1).and_then(|c| c.to_str()).map(str::to_string);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn nearest_dir_name(path: &Path) -> String {
|
||
const GENERIC_DIRS: &[&str] = &[
|
||
"bin", "binaries", "x64", "x86", "win64", "win32", "retail",
|
||
"shipping", "game", "runtime", "_retail_", "_commonredist",
|
||
"launcher", "engine", "client",
|
||
];
|
||
|
||
for d in path.ancestors().skip(1) {
|
||
let name = d.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||
let lower = name.to_lowercase();
|
||
if !name.is_empty()
|
||
&& !GENERIC_DIRS.iter().any(|g| lower == *g)
|
||
&& !lower.starts_with("program files")
|
||
{
|
||
return name.to_string();
|
||
}
|
||
}
|
||
|
||
// Nothing useful in the path — return the exe stem as-is.
|
||
path.file_stem()
|
||
.and_then(|s| s.to_str())
|
||
.unwrap_or("Unknown")
|
||
.to_string()
|
||
}
|
||
|
||
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().is_ok_and(|t| t.is_dir()) {
|
||
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!(" {} {:12} multiple prefixes:", "⚠".yellow(), l.name);
|
||
for p in matches {
|
||
println!(" {}", p.display());
|
||
}
|
||
}
|
||
Some(matches) => {
|
||
let detected = &matches[0];
|
||
if *detected == l.prefix_dir {
|
||
println!(" {} {:12} {}", "✓".green().bold(), l.name, detected.display());
|
||
} else {
|
||
any_divergent = true;
|
||
println!(
|
||
" {} {:12} {} (was {})",
|
||
"→".cyan(),
|
||
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!(" {} {:12} ambiguous — update via `config edit`", "⚠".yellow(), l.name);
|
||
continue;
|
||
}
|
||
let detected = &matches[0];
|
||
if *detected == l.prefix_dir {
|
||
println!(" {} {:12} unchanged", "✓".green().bold(), l.name);
|
||
continue;
|
||
}
|
||
println!(
|
||
" {} {:12} {} → {}",
|
||
"→".green().bold(),
|
||
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(())
|
||
}
|