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:
+94
-44
@@ -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 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<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;
|
||||
|
||||
Reference in New Issue
Block a user