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>>> =
|
static NAME_CACHE: LazyLock<Mutex<HashMap<PathBuf, String>>> =
|
||||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
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.
|
/// Scan a launcher's Wine prefix for installed game executables.
|
||||||
/// Returns (display_name, path_relative_to_drive_c) pairs, sorted alphabetically,
|
/// Returns (display_name, path_relative_to_drive_c) pairs, sorted alphabetically,
|
||||||
/// excluding the launcher's own exe and any already-configured games.
|
/// excluding the launcher's own exe and any already-configured games.
|
||||||
@@ -208,13 +284,15 @@ fn scan_exe_dir(
|
|||||||
///
|
///
|
||||||
/// Resolution pipeline (first hit wins):
|
/// Resolution pipeline (first hit wins):
|
||||||
/// 1. Explicit name — if `explicit_name` is `Some`, return it immediately.
|
/// 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
|
/// 3. Manifest walk — walks up from the exe looking for GOG `.info` and
|
||||||
/// Epic `.egstore/*.item` JSON files at the game's installation root.
|
/// Epic `.egstore/*.item` JSON files at the game's installation root.
|
||||||
/// 4. Launcher path — reads the game name from well-known directory
|
/// 4. Launcher path — reads the game name from well-known directory
|
||||||
/// structures laid down by the launcher (e.g. `Epic Games/<Name>/`).
|
/// structures laid down by the launcher (e.g. `Epic Games/<Name>/`).
|
||||||
/// 5. Heuristic fallback — nearest non-generic parent directory name,
|
/// 5. Nearest non-generic parent directory name, or raw exe stem.
|
||||||
/// or the exe stem with CamelCase / digit boundaries split into words.
|
/// 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.
|
/// 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 {
|
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 {
|
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
|
// Stage 3 – manifest files at the game's installation root
|
||||||
if let Some(name) = read_manifest_name(exe_path) {
|
if let Some(name) = read_manifest_name(exe_path) {
|
||||||
@@ -252,8 +333,9 @@ fn resolve_uncached(exe_path: &Path) -> String {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage 5 – heuristic fallback
|
// Stage 5 – nearest non-generic parent directory, or raw exe stem.
|
||||||
prettify_exe_name(exe_path)
|
// 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
|
/// 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
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Heuristic last-resort name derivation from an exe path.
|
fn nearest_dir_name(path: &Path) -> String {
|
||||||
///
|
|
||||||
/// 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 {
|
|
||||||
const GENERIC_DIRS: &[&str] = &[
|
const GENERIC_DIRS: &[&str] = &[
|
||||||
"bin", "binaries", "x64", "x86", "win64", "win32", "retail",
|
"bin", "binaries", "x64", "x86", "win64", "win32", "retail",
|
||||||
"shipping", "game", "runtime", "_retail_", "_commonredist",
|
"shipping", "game", "runtime", "_retail_", "_commonredist",
|
||||||
@@ -357,8 +435,7 @@ pub fn prettify_exe_name(path: &Path) -> String {
|
|||||||
];
|
];
|
||||||
|
|
||||||
let mut dir = path.parent();
|
let mut dir = path.parent();
|
||||||
for _ in 0..3 {
|
while let Some(d) = dir {
|
||||||
let Some(d) = dir else { break };
|
|
||||||
let name = d.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
let name = d.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||||
let lower = name.to_lowercase();
|
let lower = name.to_lowercase();
|
||||||
if !name.is_empty()
|
if !name.is_empty()
|
||||||
@@ -370,38 +447,11 @@ pub fn prettify_exe_name(path: &Path) -> String {
|
|||||||
dir = d.parent();
|
dir = d.parent();
|
||||||
}
|
}
|
||||||
|
|
||||||
let stem = path
|
// Nothing useful in the path — return the exe stem as-is.
|
||||||
.file_stem()
|
path.file_stem()
|
||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
.unwrap_or("Unknown");
|
.unwrap_or("Unknown")
|
||||||
|
.to_string()
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_DEPTH: u32 = 3;
|
const MAX_DEPTH: u32 = 3;
|
||||||
|
|||||||
Reference in New Issue
Block a user