detect: read Legendary/Heroic installed.json, remove name generation

Add stage 2: read Legendary and Heroic's installed.json files from all
known locations (native + Flatpak) to build a install-path → title map.
This covers Epic games (via Legendary) and GOG games (via Heroic's GOG
store) before any fallback is needed.

Remove CamelCase/digit-boundary splitting from the fallback entirely.
If stages 2-4 all miss, nearest_dir_name() returns the closest
non-generic parent directory name, or the raw exe stem as-is. No names
are fabricated from the exe filename.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-19 10:52:55 -07:00
parent aeed52d6dd
commit a0ee01cd5d
+94 -44
View File
@@ -118,6 +118,82 @@ const SKIP_EXES: &[&str] = &[
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> {
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/<Name>/`).
/// 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 25 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<String> {
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<char> = 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;