game_manager_lib/sources/
epic.rs1use 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#[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
49pub struct EpicSource {
51 app_handle: AppHandle,
52 #[allow(dead_code)]
53 wine_prefix: Option<PathBuf>, 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
95impl 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 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 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![]), };
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
221impl EpicSource {
224 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 let mut chosen_per_namespace: HashMap<String, (String, String)> = HashMap::new(); 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; };
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 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
387async 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
442async 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
491async fn fetch_all_catalog_titles(
493 access_token: &str,
494 by_namespace: &HashMap<String, Vec<String>>,
495) -> Result<CatalogTitles, AppError> {
496 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
528pub 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}