From 1d4c75bffdd31989954bbeb1ac024ef0a56d8762 Mon Sep 17 00:00:00 2001 From: Tickbase Date: Thu, 30 Apr 2026 21:00:42 +0200 Subject: [PATCH] Heroic library scanner #93 --- src-tauri/src/epic_scanner.rs | 184 ++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 src-tauri/src/epic_scanner.rs diff --git a/src-tauri/src/epic_scanner.rs b/src-tauri/src/epic_scanner.rs new file mode 100644 index 0000000..384fa66 --- /dev/null +++ b/src-tauri/src/epic_scanner.rs @@ -0,0 +1,184 @@ +use log::{info, warn}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EpicGame { + pub app_name: String, + pub title: String, + pub install_path: String, + pub executable: String, + pub box_art_url: Option, + pub scream_installed: bool, + pub koaloader_installed: bool, + /// True when Koaloader was installed using version.dll as a fallback + /// because no matching proxy import was detected in the game's PE files. + pub proxy_fallback_used: bool, +} + +/// Minimal fields we need from installed.json entries. +#[derive(Debug, Deserialize)] +struct InstalledEntry { + title: String, + install_path: String, + executable: String, + #[serde(default)] + is_dlc: bool, +} + +fn legendary_config_dir() -> Option { + let home = std::env::var("HOME").ok()?; + let path = PathBuf::from(&home) + .join(".config") + .join("heroic") + .join("legendaryConfig") + .join("legendary"); + if path.exists() { + Some(path) + } else { + warn!("Heroic legendary config dir not found at: {}", path.display()); + None + } +} + +pub fn scan_epic_games() -> Vec { + let legendary_dir = match legendary_config_dir() { + Some(d) => d, + None => return Vec::new(), + }; + + let installed_path = legendary_dir.join("installed.json"); + if !installed_path.exists() { + warn!("installed.json not found at: {}", installed_path.display()); + return Vec::new(); + } + + let content = match fs::read_to_string(&installed_path) { + Ok(c) => c, + Err(e) => { + warn!("Failed to read installed.json: {}", e); + return Vec::new(); + } + }; + + let installed: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(e) => { + warn!("Failed to parse installed.json: {}", e); + return Vec::new(); + } + }; + + let metadata_dir = legendary_dir.join("metadata"); + let mut games = Vec::new(); + + if let Some(obj) = installed.as_object() { + for (app_name, entry_val) in obj { + let entry: InstalledEntry = match serde_json::from_value(entry_val.clone()) { + Ok(e) => e, + Err(e) => { + warn!("Failed to parse installed entry {}: {}", app_name, e); + continue; + } + }; + + if entry.is_dlc { + continue; + } + + let install_path = PathBuf::from(&entry.install_path); + if !install_path.exists() { + warn!( + "Install path does not exist for {}: {}", + app_name, entry.install_path + ); + continue; + } + + let box_art_url = get_box_art(&metadata_dir, app_name); + let scream_installed = check_screamapi_installed(&install_path); + let koaloader_installed = check_koaloader_installed(&install_path); + + info!( + "Found Epic game: {} ({}), ScreamAPI={}, Koaloader={}", + entry.title, app_name, scream_installed, koaloader_installed + ); + + games.push(EpicGame { + app_name: app_name.clone(), + title: entry.title, + install_path: entry.install_path, + executable: entry.executable, + box_art_url, + scream_installed, + koaloader_installed, + proxy_fallback_used: false, + }); + } + } + + info!("Found {} Epic games", games.len()); + games +} + +/// Extract the "DieselGameBox" image URL from a game's metadata JSON. +/// We read the top-level keyImages array directly from the JSON value, +/// which avoids pulling in DLC images from dlcItemList. +fn get_box_art(metadata_dir: &Path, app_name: &str) -> Option { + let meta_path = metadata_dir.join(format!("{}.json", app_name)); + if !meta_path.exists() { + return None; + } + + let content = fs::read_to_string(&meta_path).ok()?; + let val: serde_json::Value = serde_json::from_str(&content).ok()?; + + let key_images = val + .get("metadata") + .and_then(|m| m.get("keyImages")) + .and_then(|k| k.as_array())?; + + // Prefer landscape (DieselGameBox), fall back to portrait or logo + for preferred in &["DieselGameBox", "DieselGameBoxTall", "DieselGameBoxLogo"] { + if let Some(url) = key_images.iter().find_map(|img| { + if img.get("type").and_then(|t| t.as_str()) == Some(preferred) { + img.get("url").and_then(|u| u.as_str()).map(str::to_owned) + } else { + None + } + }) { + return Some(url); + } + } + + None +} + +pub fn check_screamapi_installed(install_path: &Path) -> bool { + for entry in WalkDir::new(install_path) + .max_depth(8) + .into_iter() + .filter_map(Result::ok) + { + let filename = entry.file_name().to_string_lossy().to_lowercase(); + if filename.starts_with("eossdk-win") && filename.ends_with("_o.dll") { + return true; + } + } + false +} + +pub fn check_koaloader_installed(install_path: &Path) -> bool { + for entry in WalkDir::new(install_path) + .max_depth(4) + .into_iter() + .filter_map(Result::ok) + { + if entry.file_name().to_string_lossy() == "Koaloader.config.json" { + return true; + } + } + false +} \ No newline at end of file