Skip to main content

game_manager_lib/sources/
ea.rs

1//! Source para importar jogos instalados via EA App (Electronic Arts).
2//!
3//! Diferente de Epic/GOG/Battle.net, o EA App não expõe nenhum arquivo estruturado
4//! e legível com o status de instalação atual (o antigo `Origin Games` no registro do
5//! Windows existe, mas é granular por DLC/expansão, não por jogo — não confiável como
6//! fonte de identidade). Por isso a detecção depende do usuário informar a pasta onde o EA
7//! instala os jogos (configurável no client, assim como no GOG Galaxy).
8//!
9//! **Duas fontes cruzadas:**
10//! - `InstallData` (`C:\ProgramData\EA Desktop\InstallData`): pasta por jogo já instalado
11//!   alguma vez, sobrevive à desinstalação.
12//! - Pasta de instalação configurada pelo usuário: escaneada em busca de subpastas —
13//!   cada uma é um jogo atualmente instalado. Fonte de verdade sobre o que está instalado.
14//!
15//! **Observações:**
16//! - Windows apenas — o EA App não roda de forma confiável via Wine.
17//! - Sem OAuth: tentativas de capturar biblioteca completa via sessão web (WebviewWindow)
18//!   se mostraram inviáveis — só jogos instalados.
19
20use crate::errors::AppError;
21use crate::sources::providers::SourceGame;
22use crate::utils::executable_heuristics::guess_main_executable;
23use std::fs;
24use std::path::{Path, PathBuf};
25
26#[cfg(target_os = "windows")]
27const EA_INSTALL_DATA_DIR_WINDOWS: &str = r"C:\ProgramData\EA Desktop\InstallData";
28
29/// Source responsável por importar jogos instalados via EA App.
30pub struct EaSource {
31    /// Pasta configurada pelo usuário onde o EA App instala os jogos.
32    install_dir: Option<PathBuf>,
33}
34
35impl EaSource {
36    pub fn new(install_dir: Option<PathBuf>) -> Self {
37        Self { install_dir }
38    }
39
40    /// Resolve o diretório `InstallData`.
41    fn resolve_install_data_dir(&self) -> Option<PathBuf> {
42        #[cfg(target_os = "windows")]
43        {
44            let path = PathBuf::from(EA_INSTALL_DATA_DIR_WINDOWS);
45            if path.exists() {
46                return Some(path);
47            }
48        }
49        None
50    }
51
52    /// Carrega os nomes de pasta de `InstallData` (catálogo de jogos já instalados alguma vez).
53    fn load_known_names(install_data_dir: &Path) -> Vec<String> {
54        let Ok(entries) = fs::read_dir(install_data_dir) else {
55            log::warn!("Não foi possível ler InstallData: {install_data_dir:?}");
56            return Vec::new();
57        };
58
59        entries
60            .flatten()
61            .filter(|e| e.path().is_dir())
62            .filter_map(|e| {
63                let name = e.file_name().to_string_lossy().trim().to_string();
64                (!name.is_empty()).then_some(name)
65            })
66            .collect()
67    }
68
69    /// Importa jogos já instalados
70    pub async fn import_installed(&self) -> Result<Vec<SourceGame>, AppError> {
71        let known_names = self
72            .resolve_install_data_dir()
73            .map(|dir| Self::load_known_names(&dir))
74            .unwrap_or_default();
75
76        let mut games = Vec::new();
77        let mut matched_known: std::collections::HashSet<String> = std::collections::HashSet::new();
78
79        // Scan da pasta de instalação (jogos atualmente instalados)
80        if let Some(install_dir) = &self.install_dir {
81            if install_dir.exists() && install_dir.is_dir() {
82                for entry in fs::read_dir(install_dir)?.flatten() {
83                    let path = entry.path();
84                    if !path.is_dir() {
85                        continue;
86                    }
87
88                    let folder_name = entry.file_name().to_string_lossy().trim().to_string();
89                    if folder_name.is_empty() {
90                        continue;
91                    }
92
93                    let matched = known_names.iter().find(|known| {
94                        let known_lower = known.to_lowercase();
95                        let folder_lower = folder_name.to_lowercase();
96                        folder_lower.starts_with(&known_lower)
97                            || known_lower.starts_with(&folder_lower)
98                    });
99
100                    let display_name = matched.cloned().unwrap_or_else(|| folder_name.clone());
101                    if let Some(m) = matched {
102                        matched_known.insert(m.clone());
103                    }
104
105                    let executable_path =
106                        guess_main_executable(&path).map(|p| p.to_string_lossy().to_string());
107
108                    games.push(SourceGame {
109                        platform: "EA".to_string(),
110                        platform_game_id: normalize_id(&display_name),
111                        name: Some(display_name),
112                        installed: true,
113                        executable_path,
114                        install_path: Some(path.to_string_lossy().to_string()),
115                        playtime_minutes: None,
116                        last_played: None,
117                    });
118                }
119            } else if install_dir.exists() {
120                log::warn!("Caminho de instalação EA configurado não é uma pasta: {install_dir:?}");
121            }
122        }
123
124        // Jogos conhecidos via InstallData mas não encontrados na pasta de instalação
125        // (indício de que o usuário já teve/possui o jogo na conta, mesmo desinstalado).
126        for known in &known_names {
127            if matched_known.contains(known) {
128                continue;
129            }
130
131            games.push(SourceGame {
132                platform: "EA".to_string(),
133                platform_game_id: normalize_id(known),
134                name: Some(known.clone()),
135                installed: false,
136                executable_path: None,
137                install_path: None,
138                playtime_minutes: None,
139                last_played: None,
140            });
141        }
142
143        Ok(games)
144    }
145}
146
147/// Gera um `platform_game_id` estável a partir do nome — minúsculo, sem espaços,
148/// já que a EA não expõe nenhum ID numérico estável como Steam/GOG/Battle.net.
149fn normalize_id(name: &str) -> String {
150    name.to_lowercase().replace(' ', "_")
151}