Skip to main content

game_manager_lib/sources/
legacy.rs

1//! Importação de jogos da plataforma Legacy Games.
2//!
3//! Lê o arquivo `app-state.json` do launcher da Legacy Games para detectar
4//! jogos adquiridos (via compra ou giveaway), cruzando com o catálogo embutido
5//! para obter metadados como capa, descrição e verificar instalação local.
6
7use 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// === ESTRUTURA PÚBLICA DA PLATAFORMA LEGACY GAMES ===
15
16/// Jogo importado da Legacy Games com campos adicionais além do `SourceGame` padrão.
17#[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// === ESTRUTURAS INTERNAS PARA DESSERIALIZAÇÃO DO JSON DE ESTADO DO LAUNCHER ===
25
26#[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    // Alguns produtos (ex: itens de reposição, bundles sem jogos) não têm o campo `games`
48    #[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    // product_id pode vir como String ou como número inteiro no JSON
71    #[serde(deserialize_with = "deserialize_id_as_string")]
72    product_id: String,
73    // game_id também pode vir como String ou número
74    #[serde(deserialize_with = "deserialize_id_as_string")]
75    game_id: String,
76}
77
78// === NORMALIZAÇÃO DE NOMES ===
79
80/// Normaliza o nome de um jogo removendo sufixos de edição especial comuns da Legacy Games.
81///
82/// Sufixos removidos (case-insensitive):
83/// - `CE` / `C.E.` — Collector's Edition
84/// - `Collector's Edition` / `Collectors Edition`
85/// - `Special Edition` / `SE`
86/// - `Deluxe Edition` / `DE`
87/// - `Premium Edition`
88/// - `GOTY` / `Game of the Year Edition`
89///
90/// # Exemplos
91/// ```rust
92/// # use game_manager_lib::sources::legacy::normalize_game_name;
93/// assert_eq!(normalize_game_name("Fable CE"), "Fable");
94/// assert_eq!(normalize_game_name("Mystery Game Collector's Edition"), "Mystery Game");
95/// assert_eq!(normalize_game_name("Some Game - Deluxe Edition"), "Some Game");
96/// ```
97fn normalize_game_name(name: &str) -> String {
98    // Sufixos a remover, por ordem de prioridade (mais longos primeiro para evitar match parcial)
99    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        // Compara o final da string ignorando capitalização
119        if let Some(rest) = trimmed.to_lowercase().strip_suffix(&suffix.to_lowercase()) {
120            let cut = &trimmed[..rest.len()];
121            // Remove separadores opcionais antes do sufixo: espaço, hífen, vírgula, dois-pontos
122            return cut
123                .trim_end_matches(['-', ':', ',', ' '])
124                .trim()
125                .to_string();
126        }
127    }
128
129    trimmed.to_string()
130}
131
132// === SANITIZAÇÃO DE CAMINHOS ===
133
134/// Remove caracteres inválidos para nomes de arquivo/pasta no Windows.
135///
136/// A Legacy Games faz o mesmo ao criar a pasta de instalação a partir do
137/// nome do catálogo — por exemplo, `"Around The World 1: Travel to Brazil CE"`
138/// vira a pasta `Around The World 1 Travel to Brazil CE` (sem o `:`, mas com
139/// os espaços preservados e o sufixo de edição mantido). Diferente de
140/// `normalize_game_name`, esta função NÃO remove sufixos de edição — apenas
141/// os caracteres que o sistema de arquivos do Windows não aceita.
142fn sanitize_path_component(name: &str) -> String {
143    name.replace([':', '/', '\\', '*', '?', '"', '<', '>', '|'], "")
144        // Colapsa espaços duplos que podem sobrar após remover o caractere
145        .split_whitespace()
146        .collect::<Vec<_>>()
147        .join(" ")
148}
149
150// === HELPER DE DESSERIALIZAÇÃO ===
151
152/// Helper: deserializa product_id tanto como String quanto como número
153fn 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
168// === LEGACY GAMES SOURCE ===
169
170/// Provedor de jogos da Legacy Games.
171///
172/// # Exemplo
173/// ```rust,no_run
174/// # use game_manager_lib::sources::legacy::LegacySource;
175/// let source = LegacySource::new(None); // busca o caminho padrão
176/// # tokio_test::block_on(async {
177/// let games = source.fetch_games_detailed().await.unwrap();
178/// # });
179/// ```
180pub struct LegacySource {
181    /// Caminho para o arquivo `app-state-bck.json`. Se `None`, usa o caminho padrão do sistema operacional.
182    pub app_state_path: Option<PathBuf>,
183    /// Wine prefix utilizado no Linux para localizar o launcher da Legacy Games. Ignorado no Windows.
184    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    /// Retorna o caminho padrão do `app-state.json` conforme o sistema operacional.
203    fn default_app_state_path(&self) -> Option<PathBuf> {
204        #[cfg(target_os = "windows")]
205        {
206            // dirs::data_dir() retorna %APPDATA%\Roaming — caminho correto do Legacy Games Launcher
207            dirs::data_dir().map(|d| d.join("legacy-games-launcher").join("app-state.json"))
208        }
209
210        #[cfg(target_os = "linux")]
211        {
212            // No Linux só há suporte via Wine prefix configurado pelo usuário
213            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    /// Verifica se um jogo está instalado no diretório da biblioteca.
230    ///
231    /// A Legacy Games instala cada jogo em `<gameLibraryPath>/<game_name>/`, onde `<game_name>` é o
232    /// nome do catálogo com os caracteres inválidos de caminho removidos (o `:` de "Nome: Subtítulo",
233    /// por exemplo) — mas com sufixos de edição como "CE" preservados. O executável principal costuma
234    /// ter o mesmo nome, sem espaços.
235    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                // Tenta localizar o executável com o mesmo nome do jogo
247                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    /// Procura o executável `.exe` dentro do diretório de instalação.
256    ///
257    /// Estratégia: primeiro tenta `<game_name>.exe` (sem espaços nem caracteres inválidos, como a
258    /// Legacy Games nomeia o binário), depois qualquer `.exe` que não seja desinstalador ou redistributível.
259    fn find_executable(game_dir: &Path, game_name: &str) -> Option<String> {
260        // 1. Tenta o executável com o mesmo nome do jogo (sem espaços)
261        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        // 2. Busca qualquer .exe no diretório raiz, ignorando utilitários comuns
268        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    /// Busca os jogos adquiridos com metadados completos.
298    ///
299    /// Retorna [`LegacyGame`] ao invés de [`SourceGame`] puro, permitindo persistir `cover_url` e `description_raw`.
300    pub async fn fetch_games_detailed(&self) -> Result<Vec<LegacyGame>, AppError> {
301        // Resolve o caminho do arquivo de estado
302        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        // Lê e desserializa o JSON
318        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        // Monta índice game_id -> CatalogGame para busca O(1)
327        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            // Cruza game_id com o catálogo local para obter metadados
339            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            // IMPORTANTE: usa o nome cru do catálogo (com sufixo de edição, ex. "CE")para resolver
349            // a pasta de instalação, pois é assim que a Legacy Games nomeia o diretório no disco.
350            // `normalize_game_name` só deve ser aplicado ao nome exibido ao usuário, não ao caminho
351            // no sistema de arquivos.
352            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// Implementação da trait padrão (retorna apenas SourceGame, sem os extras)
378#[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}