game_manager_lib/sources/
legacy.rs1use crate::errors::AppError;
8use crate::sources::providers::SourceGame;
9use async_trait::async_trait;
10use serde::Deserialize;
11use std::path::{Path, PathBuf};
12use tracing::warn;
13
14#[derive(Debug, Clone)]
18pub struct LegacyGame {
19 pub source: SourceGame,
20 pub cover_url: Option<String>,
21 pub description_raw: Option<String>,
22}
23
24#[derive(Deserialize)]
27struct AppStateJson {
28 settings: LegacySettings,
29 #[serde(rename = "siteData")]
30 site_data: SiteData,
31 user: UserData,
32}
33
34#[derive(Deserialize)]
35struct LegacySettings {
36 #[serde(rename = "gameLibraryPath")]
37 game_library_path: Vec<String>,
38}
39
40#[derive(Deserialize)]
41struct SiteData {
42 catalog: Vec<CatalogProduct>,
43}
44
45#[derive(Deserialize)]
46struct CatalogProduct {
47 #[serde(default)]
49 games: Vec<CatalogGame>,
50}
51
52#[derive(Deserialize)]
53struct CatalogGame {
54 game_id: String,
55 game_name: String,
56 #[serde(rename = "game_description")]
57 game_description: Option<String>,
58 #[serde(rename = "game_coverart")]
59 game_coverart: Option<String>,
60}
61
62#[derive(Deserialize)]
63struct UserData {
64 #[serde(rename = "giveawayDownloads")]
65 giveaway_downloads: Vec<AcquiredGame>,
66}
67
68#[derive(Deserialize)]
69struct AcquiredGame {
70 #[serde(deserialize_with = "deserialize_id_as_string")]
72 product_id: String,
73 #[serde(deserialize_with = "deserialize_id_as_string")]
75 game_id: String,
76}
77
78fn normalize_game_name(name: &str) -> String {
98 const SUFFIXES: &[&str] = &[
100 "Collector's Edition",
101 "Collectors Edition",
102 "Collection Edition",
103 "Game of the Year Edition",
104 "Special Edition",
105 "Deluxe Edition",
106 "Premium Edition",
107 "GOTY Edition",
108 "C.E.",
109 "GOTY",
110 "CE",
111 "SE",
112 "DE",
113 ];
114
115 let trimmed = name.trim();
116
117 for suffix in SUFFIXES {
118 if let Some(rest) = trimmed.to_lowercase().strip_suffix(&suffix.to_lowercase()) {
120 let cut = &trimmed[..rest.len()];
121 return cut
123 .trim_end_matches(['-', ':', ',', ' '])
124 .trim()
125 .to_string();
126 }
127 }
128
129 trimmed.to_string()
130}
131
132fn sanitize_path_component(name: &str) -> String {
143 name.replace([':', '/', '\\', '*', '?', '"', '<', '>', '|'], "")
144 .split_whitespace()
146 .collect::<Vec<_>>()
147 .join(" ")
148}
149
150fn deserialize_id_as_string<'de, D>(deserializer: D) -> Result<String, D::Error>
154where
155 D: serde::Deserializer<'de>,
156{
157 let v: serde_json::Value = serde::Deserialize::deserialize(deserializer)?;
158 match v {
159 serde_json::Value::String(s) => Ok(s),
160 serde_json::Value::Number(n) => Ok(n.to_string()),
161 other => Err(<D::Error as serde::de::Error>::custom(format!(
162 "expected string or number, got {:?}",
163 other
164 ))),
165 }
166}
167
168pub struct LegacySource {
181 pub app_state_path: Option<PathBuf>,
183 pub wine_prefix: Option<PathBuf>,
185}
186
187impl LegacySource {
188 pub fn new(app_state_path: Option<PathBuf>) -> Self {
189 Self {
190 app_state_path,
191 wine_prefix: None,
192 }
193 }
194
195 pub fn new_with_wine(app_state_path: Option<PathBuf>, wine_prefix: Option<PathBuf>) -> Self {
196 Self {
197 app_state_path,
198 wine_prefix,
199 }
200 }
201
202 fn default_app_state_path(&self) -> Option<PathBuf> {
204 #[cfg(target_os = "windows")]
205 {
206 dirs::data_dir().map(|d| d.join("legacy-games-launcher").join("app-state.json"))
208 }
209
210 #[cfg(target_os = "linux")]
211 {
212 let prefix = self.wine_prefix.as_ref()?;
214 let user = std::env::var("USER").ok()?;
215
216 Some(
217 prefix
218 .join("drive_c")
219 .join("users")
220 .join(&user)
221 .join("AppData")
222 .join("Roaming")
223 .join("legacy-games-launcher")
224 .join("app-state.json"),
225 )
226 }
227 }
228
229 fn resolve_install_info(
236 library_paths: &[String],
237 game_name: &str,
238 ) -> (bool, Option<String>, Option<String>) {
239 let sanitized_name = sanitize_path_component(game_name);
240
241 for base in library_paths {
242 let game_dir = Path::new(base).join(&sanitized_name);
243 if game_dir.is_dir() {
244 let install_path = game_dir.to_string_lossy().to_string();
245
246 let exe_path = Self::find_executable(&game_dir, &sanitized_name);
248
249 return (true, Some(install_path), exe_path);
250 }
251 }
252 (false, None, None)
253 }
254
255 fn find_executable(game_dir: &Path, game_name: &str) -> Option<String> {
260 let sanitized = sanitize_path_component(game_name).replace(' ', "");
262 let candidate = game_dir.join(format!("{}.exe", sanitized));
263 if candidate.is_file() {
264 return Some(candidate.to_string_lossy().to_string());
265 }
266
267 let ignored = [
269 "unins",
270 "redist",
271 "setup",
272 "vc_redist",
273 "dxsetup",
274 "directx",
275 ];
276
277 if let Ok(entries) = std::fs::read_dir(game_dir) {
278 for entry in entries.flatten() {
279 let path = entry.path();
280 if path.extension().and_then(|e| e.to_str()) == Some("exe") {
281 let file_stem = path
282 .file_stem()
283 .and_then(|s| s.to_str())
284 .unwrap_or("")
285 .to_lowercase();
286
287 if !ignored.iter().any(|&ig| file_stem.contains(ig)) {
288 return Some(path.to_string_lossy().to_string());
289 }
290 }
291 }
292 }
293
294 None
295 }
296
297 pub async fn fetch_games_detailed(&self) -> Result<Vec<LegacyGame>, AppError> {
301 let state_path = self
303 .app_state_path
304 .clone()
305 .or_else(|| self.default_app_state_path())
306 .ok_or_else(|| {
307 AppError::NotFound("Caminho do app-state da Legacy Games não encontrado.".into())
308 })?;
309
310 if !state_path.exists() {
311 return Err(AppError::NotFound(format!(
312 "Arquivo de estado da Legacy Games não encontrado em: {}",
313 state_path.display()
314 )));
315 }
316
317 let content =
319 std::fs::read_to_string(&state_path).map_err(|e| AppError::IoError(e.to_string()))?;
320
321 let app_state: AppStateJson = serde_json::from_str(&content)
322 .map_err(|e| AppError::SerializationError(e.to_string()))?;
323
324 let library_paths = &app_state.settings.game_library_path;
325
326 let catalog_index: std::collections::HashMap<&str, &CatalogGame> = app_state
328 .site_data
329 .catalog
330 .iter()
331 .flat_map(|product| product.games.iter())
332 .map(|game| (game.game_id.as_str(), game))
333 .collect();
334
335 let mut results: Vec<LegacyGame> = Vec::new();
336
337 for acquired in &app_state.user.giveaway_downloads {
338 let Some(catalog_game) = catalog_index.get(acquired.game_id.as_str()) else {
340 warn!(
341 game_id = %acquired.game_id,
342 product_id = %acquired.product_id,
343 "Jogo não encontrado no catálogo — pode ter sido removido da Legacy Games. Ignorado."
344 );
345 continue;
346 };
347
348 let (installed, install_path, executable_path) =
353 Self::resolve_install_info(library_paths, &catalog_game.game_name);
354
355 let source = SourceGame {
356 platform: "LegacyGames".to_string(),
357 platform_game_id: acquired.product_id.clone(),
358 name: Some(normalize_game_name(&catalog_game.game_name)),
359 installed,
360 executable_path,
361 install_path,
362 playtime_minutes: Some(0),
363 last_played: None,
364 };
365
366 results.push(LegacyGame {
367 source,
368 cover_url: catalog_game.game_coverart.clone(),
369 description_raw: catalog_game.game_description.clone(),
370 });
371 }
372
373 Ok(results)
374 }
375}
376
377#[async_trait]
379impl crate::sources::providers::GameSource for LegacySource {
380 async fn fetch_games(&self) -> Result<Vec<SourceGame>, AppError> {
381 let detailed = self.fetch_games_detailed().await?;
382 Ok(detailed.into_iter().map(|g| g.source).collect())
383 }
384}