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 { let roots: Vec = 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>> = LazyLock::new(|| Mutex::new(HashMap::new())); /// Install-path → display title, built once from Legendary / Heroic metadata. static STORE_TITLES: LazyLock> = 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 { 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) { let Ok(json) = serde_json::from_str::(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) { let Ok(json) = serde_json::from_str::(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 { 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 = 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 = 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, out: &mut Vec<(String, String)>, seen: &mut HashSet, 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//`). /// 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-.info` → `{ "gameName": "..." }` /// - Epic: `.egstore/.item` → `{ "DisplayName": "..." }` fn read_manifest_name(exe_path: &Path) -> Option { 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 { 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 { 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//…` /// - GOG: `…/GOG Games//…` /// - Steam: `…/steamapps/common//…` /// - Rockstar:`…/Rockstar Games//…` /// - EA: `…/EA Games//…` /// - Ubisoft: `…/Ubisoft Game Launcher/games//…` fn name_from_launcher_path(exe_path: &Path) -> Option { 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//… "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 = 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().is_ok_and(|t| t.is_dir()) { 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!(" {} {: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>) -> 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(()) }