diff --git a/src/detect.rs b/src/detect.rs index c7263cf..448d469 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -118,6 +118,82 @@ const SKIP_EXES: &[&str] = &[ 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 { + let mut dir = exe_path.parent(); + while let Some(d) = dir { + if let Some(title) = STORE_TITLES.get(d) { + return Some(title.clone()); + } + dir = d.parent(); + } + None +} + /// 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. @@ -208,13 +284,15 @@ fn scan_exe_dir( /// /// Resolution pipeline (first hit wins): /// 1. Explicit name — if `explicit_name` is `Some`, return it immediately. -/// 2. Store/platform metadata — reserved for future integration. +/// 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. Heuristic fallback — nearest non-generic parent directory name, -/// or the exe stem with CamelCase / digit boundaries split into words. +/// 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 { @@ -240,7 +318,10 @@ pub fn resolve_game_name(exe_path: &Path, explicit_name: Option<&str>) -> String } fn resolve_uncached(exe_path: &Path) -> String { - // Stage 2 – store/platform metadata (future integration point) + // 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) { @@ -252,8 +333,9 @@ fn resolve_uncached(exe_path: &Path) -> String { return name; } - // Stage 5 – heuristic fallback - prettify_exe_name(exe_path) + // 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 @@ -345,11 +427,7 @@ fn name_from_launcher_path(exe_path: &Path) -> Option { None } -/// Heuristic last-resort name derivation from an exe path. -/// -/// Walks up parent directories looking for a non-generic name; falls back to -/// inserting spaces into the CamelCase / digit-boundary exe stem. -pub fn prettify_exe_name(path: &Path) -> String { +fn nearest_dir_name(path: &Path) -> String { const GENERIC_DIRS: &[&str] = &[ "bin", "binaries", "x64", "x86", "win64", "win32", "retail", "shipping", "game", "runtime", "_retail_", "_commonredist", @@ -357,8 +435,7 @@ pub fn prettify_exe_name(path: &Path) -> String { ]; let mut dir = path.parent(); - for _ in 0..3 { - let Some(d) = dir else { break }; + while let Some(d) = dir { let name = d.file_name().and_then(|n| n.to_str()).unwrap_or(""); let lower = name.to_lowercase(); if !name.is_empty() @@ -370,38 +447,11 @@ pub fn prettify_exe_name(path: &Path) -> String { dir = d.parent(); } - let stem = path - .file_stem() + // Nothing useful in the path — return the exe stem as-is. + path.file_stem() .and_then(|s| s.to_str()) - .unwrap_or("Unknown"); - - // Insert spaces at CamelCase and digit boundaries. - let mut out = String::with_capacity(stem.len() + 4); - let chars: Vec = stem.chars().collect(); - for (i, &c) in chars.iter().enumerate() { - if i > 0 { - let prev = chars[i - 1]; - if (prev.is_alphabetic() && c.is_ascii_digit()) - || (prev.is_ascii_digit() && c.is_alphabetic()) - { - out.push(' '); - } else if prev.is_lowercase() && c.is_uppercase() { - out.push(' '); - } else if i + 1 < chars.len() - && prev.is_uppercase() - && c.is_uppercase() - && chars[i + 1].is_lowercase() - { - out.push(' '); - } - } - if c == '_' || c == '-' { - out.push(' '); - } else { - out.push(c); - } - } - out + .unwrap_or("Unknown") + .to_string() } const MAX_DEPTH: u32 = 3;