Skip to main content

game_manager_lib/sources/
epic.rs

1//! Source para importar jogos da Epic Games
2//!
3//! Detecta jogos instalados lendo os arquivos de manifesto `.item` do Epic Games Launcher.
4//! Importa a biblioteca completa via OAuth2 (login na conta Epic).
5//!
6//! **Observações:**
7//! - **Windows:** os manifestos estão em `C:\ProgramData\Epic\EpicGamesLauncher\Data\Manifests`.
8//! - **Linux (Wine):** o mesmo caminho é resolvido dentro do Wine prefix em
9//!   `<prefix>/drive_c/ProgramData/Epic/EpicGamesLauncher/Data/Manifests`.
10//! - Cada arquivo `.item` é um JSON com nome, caminho de instalação e executável do jogo.
11
12use crate::constants::{
13    EPIC_CATALOG_BULK_ENDPOINT, EPIC_LIBRARY_ENDPOINT, EPIC_LOGIN_URL, EPIC_OAUTH_CLIENT_ID,
14    EPIC_OAUTH_CLIENT_SECRET, EPIC_PSEUDO_REDIRECT_SCHEME, EPIC_REDIRECT_PREFIX,
15    EPIC_TOKEN_ENDPOINT, OAUTH_CALLBACK_TIMEOUT_SECS,
16};
17use crate::errors::AppError;
18use crate::sources::providers::{GameSource, OAuthGameSource, SourceGame};
19use crate::utils::http_client::HTTP_CLIENT;
20use crate::utils::oauth::config::{
21    exchange_code_for_token, OAuthProviderConfig, TokenAuthMethod, TokenRequestMethod,
22};
23use crate::utils::oauth::token_store::save_oauth_token;
24use async_trait::async_trait;
25use futures::stream::{self, StreamExt};
26use serde::Deserialize;
27use std::collections::HashMap;
28use std::fs;
29use std::path::{Path, PathBuf};
30use std::sync::mpsc;
31use std::time::Duration;
32use tauri::{AppHandle, WebviewUrl, WebviewWindowBuilder};
33
34type CatalogTitles = HashMap<(String, String), (String, bool)>;
35type CatalogResolution = Result<(String, HashMap<String, (String, bool)>), AppError>;
36
37// === STRUCTS ===
38
39/// Estrutura mínima do JSON dos arquivos `.item`
40#[derive(Debug, Deserialize)]
41#[serde(rename_all = "PascalCase")]
42struct EpicManifest {
43    display_name: Option<String>,
44    install_location: Option<String>,
45    launch_executable: Option<String>,
46    app_name: Option<String>,
47}
48
49/// Source responsável por importar jogos instalados via Epic Games
50pub struct EpicSource {
51    app_handle: AppHandle,
52    #[allow(dead_code)]
53    wine_prefix: Option<PathBuf>, // Wine prefix utilizado no Linux para localizar os manifestos do Epic. Ignorado no Windows.
54    config: OAuthProviderConfig,
55}
56
57#[derive(Debug, Deserialize, Clone)]
58pub struct EpicLibraryItem {
59    pub(crate) namespace: String,
60    #[serde(rename = "catalogItemId")]
61    pub(crate) catalog_item_id: String,
62}
63
64#[derive(Debug, Deserialize)]
65struct EpicLibraryResponse {
66    records: Vec<EpicLibraryItem>,
67    #[serde(rename = "responseMetadata", default)]
68    response_metadata: Option<EpicLibraryMetadata>,
69}
70
71#[derive(Debug, Deserialize)]
72struct EpicLibraryMetadata {
73    #[serde(rename = "nextCursor", default)]
74    next_cursor: Option<String>,
75}
76
77#[derive(Debug, Deserialize)]
78struct EpicCatalogItem {
79    title: String,
80    #[serde(default)]
81    categories: Vec<EpicCategory>,
82}
83
84#[derive(Debug, Deserialize)]
85struct EpicCategory {
86    path: String,
87}
88
89impl EpicCatalogItem {
90    fn is_game(&self) -> bool {
91        self.categories.iter().any(|c| c.path == "games")
92    }
93}
94
95// === JOGOS INSTALADOS ===
96
97impl EpicSource {
98    pub fn new(app_handle: AppHandle, wine_prefix: Option<PathBuf>) -> Self {
99        let config = OAuthProviderConfig {
100            provider_id: "epic",
101            client_id: EPIC_OAUTH_CLIENT_ID.to_string(),
102            client_secret: Some(EPIC_OAUTH_CLIENT_SECRET.to_string()),
103            authorize_endpoint: EPIC_LOGIN_URL.to_string(),
104            token_endpoint: EPIC_TOKEN_ENDPOINT.to_string(),
105            redirect_uri: EPIC_REDIRECT_PREFIX.to_string(),
106            scopes: vec![],
107            uses_pkce: false,
108            extra_params: vec![],
109            token_request_method: TokenRequestMethod::Post,
110            token_auth_method: TokenAuthMethod::BasicHeader,
111        };
112
113        Self {
114            app_handle,
115            wine_prefix,
116            config,
117        }
118    }
119
120    /// Resolve o diretório de manifestos do Epic Games Launcher.
121    ///
122    /// - **Windows:** `C:\ProgramData\Epic\EpicGamesLauncher\Data\Manifests`
123    /// - **Linux:** `<wine_prefix>/drive_c/ProgramData/Epic/EpicGamesLauncher/Data/Manifests`
124    fn resolve_manifest_dir(&self) -> Option<PathBuf> {
125        #[cfg(target_os = "windows")]
126        {
127            use crate::constants::EPIC_MANIFEST_PATH_WINDOWS;
128            let path = PathBuf::from(EPIC_MANIFEST_PATH_WINDOWS);
129            if path.exists() {
130                return Some(path);
131            }
132        }
133
134        #[cfg(target_os = "linux")]
135        {
136            if let Some(prefix) = &self.wine_prefix {
137                let path = prefix
138                    .join("drive_c")
139                    .join("ProgramData")
140                    .join("Epic")
141                    .join("EpicGamesLauncher")
142                    .join("Data")
143                    .join("Manifests");
144                if path.exists() {
145                    return Some(path);
146                }
147            }
148        }
149
150        None
151    }
152
153    /// Importa todos os jogos instalados detectados nos manifestos locais
154    pub async fn import_installed(&self) -> Result<Vec<SourceGame>, AppError> {
155        let manifest_dir = match self.resolve_manifest_dir() {
156            Some(dir) => dir,
157            None => return Ok(vec![]), // Epic não instalada ou sem jogos
158        };
159
160        let mut games = Vec::new();
161
162        for entry in fs::read_dir(&manifest_dir)? {
163            let entry = entry?;
164            let path = entry.path();
165
166            if !is_item_file(&path) {
167                continue;
168            }
169
170            match Self::parse_manifest(&path) {
171                Ok(game) => games.push(game),
172                Err(err) => {
173                    log::warn!("Erro ao processar manifest {:?}: {}", path, err);
174                    continue;
175                }
176            }
177        }
178
179        Ok(games)
180    }
181
182    fn parse_manifest(path: &Path) -> Result<SourceGame, AppError> {
183        let content = fs::read_to_string(path)?;
184
185        let manifest: EpicManifest = serde_json::from_str(&content)?;
186
187        let name = manifest
188            .display_name
189            .unwrap_or_else(|| "Unknown".to_string());
190
191        let install_location = manifest
192            .install_location
193            .ok_or_else(|| AppError::EpicMissingField("InstallLocation".into()))?;
194
195        let launch_executable = manifest
196            .launch_executable
197            .ok_or_else(|| AppError::EpicMissingField("LaunchExecutable".into()))?;
198
199        let app_name = manifest
200            .app_name
201            .ok_or_else(|| AppError::EpicMissingField("AppName".into()))?;
202
203        let full_executable_path = Path::new(&install_location)
204            .join(&launch_executable)
205            .to_string_lossy()
206            .to_string();
207
208        Ok(SourceGame {
209            platform: "Epic".to_string(),
210            platform_game_id: app_name,
211            name: Some(name),
212            installed: true,
213            executable_path: Some(full_executable_path),
214            install_path: Some(install_location),
215            playtime_minutes: None,
216            last_played: None,
217        })
218    }
219}
220
221// === BIBLIOTECA COMPLETA (OAuth) ===
222
223impl EpicSource {
224    /// Importa a biblioteca completa de jogos possuídos na conta Epic (requer login prévio).
225    pub async fn fetch_library_detailed(&self) -> Result<Vec<SourceGame>, AppError> {
226        let access_token = self.ensure_valid_token().await?;
227        let items = fetch_all_library_items(&access_token).await?;
228
229        let mut by_namespace: HashMap<String, Vec<String>> = HashMap::new();
230        for item in &items {
231            if item.namespace == "ue" {
232                continue;
233            }
234            by_namespace
235                .entry(item.namespace.clone())
236                .or_default()
237                .push(item.catalog_item_id.clone());
238        }
239
240        let titles = fetch_all_catalog_titles(&access_token, &by_namespace).await?;
241
242        // Escolhe, por namespace, o catalogItemId marcado como "games" no catálogo.
243        let mut chosen_per_namespace: HashMap<String, (String, String)> = HashMap::new(); // namespace -> (catalog_item_id, title)
244
245        for item in &items {
246            if item.namespace == "ue" {
247                continue;
248            }
249
250            let Some((title, is_game)) =
251                titles.get(&(item.namespace.clone(), item.catalog_item_id.clone()))
252            else {
253                continue; // catálogo não resolveu esse item; ignora, não usa como fallback de namespace
254            };
255
256            if *is_game {
257                chosen_per_namespace
258                    .entry(item.namespace.clone())
259                    .or_insert_with(|| (item.catalog_item_id.clone(), title.clone()));
260            }
261        }
262
263        let games = chosen_per_namespace
264            .into_iter()
265            .map(|(namespace, (_, title))| SourceGame {
266                platform: "Epic".to_string(),
267                platform_game_id: namespace,
268                name: Some(title),
269                installed: false,
270                executable_path: None,
271                install_path: None,
272                playtime_minutes: None,
273                last_played: None,
274            })
275            .collect();
276
277        Ok(games)
278    }
279}
280
281#[async_trait]
282impl GameSource for EpicSource {
283    async fn fetch_games(&self) -> Result<Vec<SourceGame>, AppError> {
284        self.fetch_library_detailed().await
285    }
286}
287
288#[async_trait]
289impl OAuthGameSource for EpicSource {
290    fn oauth_config(&self) -> &OAuthProviderConfig {
291        &self.config
292    }
293
294    fn app_handle(&self) -> &AppHandle {
295        &self.app_handle
296    }
297
298    /// A Epic não devolve `code` na query do redirect (como o GOG) — o `authorizationCode`
299    /// vem como JSON no corpo da própria página de redirect. Um script injetado via
300    /// `on_page_load` lê esse corpo e navega para uma pseudo-URL própria
301    /// (`playlite://epic-auth-code?data=...`), que o `on_navigation` consegue interceptar
302    /// (ele só recebe a URL da navegação, nunca o conteúdo da página).
303    async fn login(&self) -> Result<(), AppError> {
304        let config = self.oauth_config();
305        let login_url = url::Url::parse(&config.authorize_endpoint)
306            .map_err(|e| AppError::OAuthConfigError(format!("URL de login Epic inválida: {e}")))?;
307
308        let (tx, rx) = mpsc::channel::<Result<String, String>>();
309        let tx_nav = tx.clone();
310
311        let window = WebviewWindowBuilder::new(
312            &self.app_handle,
313            "epic_oauth_login",
314            WebviewUrl::External(login_url),
315        )
316            .title("Login Epic Games")
317            .inner_size(480.0, 720.0)
318            .on_navigation(move |url| {
319                let url_str = url.as_str();
320                if url_str.starts_with(EPIC_PSEUDO_REDIRECT_SCHEME) {
321                    let data = url
322                        .query_pairs()
323                        .find(|(k, _)| k == "data")
324                        .map(|(_, v)| v.to_string());
325                    let result =
326                        data.ok_or_else(|| "Payload 'data' ausente no callback Epic".to_string());
327                    let _ = tx_nav.send(result);
328                    return false;
329                }
330                true
331            })
332            .on_page_load(move |window, payload| {
333                if payload.url().as_str().starts_with(EPIC_REDIRECT_PREFIX) {
334                    let script = format!(
335                        r#"(function() {{
336                        try {{
337                            var data = document.body.innerText || document.body.textContent || "";
338                            window.location.href = "{scheme}?data=" + encodeURIComponent(data);
339                        }} catch (e) {{
340                            window.location.href = "{scheme}?data=" + encodeURIComponent(JSON.stringify({{error: String(e)}}));
341                        }}
342                    }})();"#,
343                        scheme = EPIC_PSEUDO_REDIRECT_SCHEME
344                    );
345
346                    let _ = window.eval(&script);
347                }
348            })
349            .build()
350            .map_err(|e| {
351                AppError::OAuthConfigError(format!("Falha ao abrir janela de login Epic: {e}"))
352            })?;
353
354        let raw_json = tokio::task::spawn_blocking(move || {
355            rx.recv_timeout(Duration::from_secs(OAUTH_CALLBACK_TIMEOUT_SECS))
356        })
357        .await
358        .map_err(|e| AppError::OAuthConfigError(format!("Task de callback falhou: {e}")))?
359        .map_err(|_| AppError::OAuthConfigError("Tempo limite de login excedido".to_string()))?
360        .map_err(AppError::OAuthConfigError)?;
361
362        let _ = window.close();
363
364        #[derive(Deserialize)]
365        struct EpicAuthRedirect {
366            #[serde(rename = "authorizationCode")]
367            authorization_code: Option<String>,
368        }
369
370        let parsed: EpicAuthRedirect = serde_json::from_str(&raw_json).map_err(|e| {
371            AppError::OAuthConfigError(format!("Resposta de login Epic inesperada: {e}"))
372        })?;
373
374        let code = parsed.authorization_code.ok_or_else(|| {
375            AppError::OAuthConfigError(
376                "Epic não retornou 'authorizationCode' — login cancelado ou falhou".to_string(),
377            )
378        })?;
379
380        let token_response = exchange_code_for_token(config, &code, None).await?;
381        save_oauth_token(&self.app_handle, config.provider_id, &token_response.into())?;
382
383        Ok(())
384    }
385}
386
387// === FUNÇÕES AUXILIARES: biblioteca + catálogo ===
388
389/// Busca a biblioteca completa da Epic Games, paginando via cursor.
390///
391/// **Importante:** `includeMetadata` precisa ser `"true"` — com `"false"`, a API omite
392/// `responseMetadata`/`nextCursor` da resposta e retorna só a primeira página sem sinalizar
393/// que há mais páginas disponíveis.
394async fn fetch_all_library_items(access_token: &str) -> Result<Vec<EpicLibraryItem>, AppError> {
395    let mut all = Vec::new();
396    let mut cursor: Option<String> = None;
397
398    loop {
399        let mut request = HTTP_CLIENT
400            .get(EPIC_LIBRARY_ENDPOINT)
401            .bearer_auth(access_token)
402            .query(&[("includeMetadata", "true")]);
403
404        if let Some(c) = &cursor {
405            request = request.query(&[("cursor", c)]);
406        }
407
408        let response = request.send().await?;
409        let status = response.status();
410        let body = response.text().await.unwrap_or_default();
411
412        if !status.is_success() {
413            return Err(AppError::NetworkError(format!(
414                "Epic library retornou HTTP {status}: {body}"
415            )));
416        }
417
418        let parsed: EpicLibraryResponse = serde_json::from_str(&body).map_err(|e| {
419            AppError::ParseError(format!(
420                "Falha ao parsear biblioteca Epic: {e} — corpo: {body}"
421            ))
422        })?;
423
424        let record_count = parsed.records.len();
425        all.extend(parsed.records);
426
427        let next_cursor = parsed.response_metadata.and_then(|m| m.next_cursor);
428        log::debug!(
429            "Epic library: página com {record_count} itens (total acumulado: {})",
430            all.len()
431        );
432
433        match next_cursor {
434            Some(c) if !c.is_empty() => cursor = Some(c),
435            _ => break,
436        }
437    }
438
439    Ok(all)
440}
441
442/// Resolve os títulos reais dos itens de um namespace via catálogo.
443async fn fetch_catalog_titles(
444    access_token: &str,
445    namespace: &str,
446    catalog_item_ids: &[String],
447) -> Result<HashMap<String, (String, bool)>, AppError> {
448    if catalog_item_ids.is_empty() {
449        return Ok(HashMap::new());
450    }
451
452    let url = format!("{EPIC_CATALOG_BULK_ENDPOINT}/{namespace}/bulk/items");
453
454    let mut query: Vec<(&str, &str)> = catalog_item_ids
455        .iter()
456        .map(|id| ("id", id.as_str()))
457        .collect();
458    query.push(("includeDLCDetails", "false"));
459    query.push(("includeMainGameDetails", "false"));
460
461    let response = HTTP_CLIENT
462        .get(&url)
463        .bearer_auth(access_token)
464        .query(&query)
465        .send()
466        .await?;
467
468    let status = response.status();
469    let body = response.text().await.unwrap_or_default();
470
471    if !status.is_success() {
472        log::warn!("Epic catalog (namespace={namespace}) retornou HTTP {status}: {body}");
473        return Ok(HashMap::new());
474    }
475
476    let parsed: HashMap<String, EpicCatalogItem> = serde_json::from_str(&body).map_err(|e| {
477        AppError::ParseError(format!(
478            "Falha ao parsear catálogo Epic: {e} — corpo: {body}"
479        ))
480    })?;
481
482    Ok(parsed
483        .into_iter()
484        .map(|(id, item)| {
485            let is_game = item.is_game();
486            (id, (item.title, is_game))
487        })
488        .collect())
489}
490
491/// Resolve os títulos de todos os namespaces em paralelo (até 8 chamadas simultâneas) em vez de sequencialmente — reduz o tempo de import.
492async fn fetch_all_catalog_titles(
493    access_token: &str,
494    by_namespace: &HashMap<String, Vec<String>>,
495) -> Result<CatalogTitles, AppError> {
496    // Clonar o token de acesso para que cada tarefa assíncrona possua seu próprio dono do token e não dependa de um borrow com lifetime restrito.
497    let access_owned = access_token.to_string();
498
499    let jobs: Vec<(String, Vec<String>)> = by_namespace
500        .iter()
501        .map(|(namespace, ids)| (namespace.clone(), ids.clone()))
502        .collect();
503
504    let results: Vec<CatalogResolution> =
505        stream::iter(jobs)
506            .map(move |(namespace, ids)| {
507                let access = access_owned.clone();
508                async move {
509                    let resolved = fetch_catalog_titles(&access, &namespace, &ids).await?;
510                    Ok::<_, AppError>((namespace, resolved))
511                }
512            })
513            .buffer_unordered(8)
514            .collect()
515            .await;
516
517    let mut titles = HashMap::new();
518    for result in results {
519        let (namespace, resolved) = result?;
520        for (catalog_item_id, (title, is_game)) in resolved {
521            titles.insert((namespace.clone(), catalog_item_id), (title, is_game));
522        }
523    }
524
525    Ok(titles)
526}
527
528/// Cruza a biblioteca completa (OAuth) com os jogos detectados localmente via manifesto,
529/// marcando como instalados os que baterem por nome (case-insensitive) — já que o `AppName`
530/// do manifesto local e o `catalogItemId` da API não são o mesmo ID. Jogos instalados que,
531/// por algum motivo, não aparecerem na biblioteca são mantidos como entradas à parte.
532pub fn merge_local_install_status(
533    library_games: &mut Vec<SourceGame>,
534    local_games: Vec<SourceGame>,
535) {
536    for local in local_games {
537        let local_name = local
538            .name
539            .as_deref()
540            .unwrap_or_default()
541            .trim()
542            .to_lowercase();
543
544        let matched = library_games
545            .iter_mut()
546            .find(|g| g.name.as_deref().unwrap_or_default().trim().to_lowercase() == local_name);
547
548        match matched {
549            Some(g) => {
550                g.installed = true;
551                g.executable_path = local.executable_path;
552                g.install_path = local.install_path;
553            }
554            None => library_games.push(local),
555        }
556    }
557}
558
559fn is_item_file(path: &Path) -> bool {
560    path.extension()
561        .and_then(|s| s.to_str())
562        .map(|ext| ext.eq_ignore_ascii_case("item"))
563        .unwrap_or(false)
564}