egs_api/lib.rs
1#![deny(missing_docs)]
2#![cfg_attr(test, deny(warnings))]
3
4//! # Epic Games Store API
5//!
6//! Async Rust client for the Epic Games Store API. Provides authentication,
7//! asset management, download manifest parsing (binary + JSON), and
8//! [Fab](https://www.fab.com/) marketplace integration.
9//!
10//! # Quick Start
11//!
12//! ```rust,no_run
13//! use egs_api::EpicGames;
14//!
15//! #[tokio::main]
16//! async fn main() {
17//! let mut egs = EpicGames::new();
18//!
19//! // Authenticate with an authorization code
20//! let code = "your_authorization_code".to_string();
21//! if egs.auth_code(None, Some(code)).await {
22//! println!("Logged in as {}", egs.user_details().display_name.unwrap_or_default());
23//! }
24//!
25//! // List all owned assets
26//! let assets = egs.list_assets(None, None).await;
27//! println!("You own {} assets", assets.len());
28//! }
29//! ```
30//!
31//! # Authentication
32//!
33//! Epic uses OAuth2 with a public launcher client ID. The flow is:
34//!
35//! 1. Open the [authorization URL] in a browser — the user logs in and gets
36//! redirected to a JSON page with an `authorizationCode`.
37//! 2. Pass that code to [`EpicGames::auth_code`].
38//! 3. Persist the session with [`EpicGames::user_details`] (implements
39//! `Serialize` / `Deserialize`).
40//! 4. Restore it later with [`EpicGames::set_user_details`] +
41//! [`EpicGames::login`], which uses the refresh token.
42//!
43//! [authorization URL]: https://www.epicgames.com/id/login?redirectUrl=https%3A%2F%2Fwww.epicgames.com%2Fid%2Fapi%2Fredirect%3FclientId%3D34a02cf8f4414e29b15921876da36f9a%26responseType%3Dcode
44//!
45//! # Features
46//!
47//! - **Assets** — List owned assets, fetch catalog metadata (with DLC trees),
48//! retrieve asset manifests with CDN download URLs.
49//! - **Download Manifests** — Parse Epic's binary and JSON manifest formats.
50//! Exposes file lists, chunk hashes, and custom fields for download
51//! reconstruction.
52//! - **Fab Marketplace** — List Fab library items, fetch signed asset manifests,
53//! and download manifests from distribution points.
54//! - **Account** — Details, bulk ID lookup, friends list.
55//! - **Entitlements** — Games, DLC, subscriptions.
56//! - **Library** — Paginated listing with optional metadata.
57//! - **Tokens** — Game exchange tokens and per-asset ownership tokens (JWT).
58//!
59//! # Architecture
60//!
61//! [`EpicGames`] is the public facade. It wraps an internal `EpicAPI` struct
62//! that holds the `reqwest::Client` (with cookie store) and session state.
63//! Most public methods return `Option<T>` or `Vec<T>`, swallowing transport
64//! errors for convenience. Fab methods return `Result<T, EpicAPIError>` to
65//! expose timeout/error distinctions.
66//!
67//! # Examples
68//!
69//! The crate ships with examples covering every endpoint. See the
70//! [`examples/`](https://github.com/AchetaGames/egs-api-rs/tree/master/examples)
71//! directory or run:
72//!
73//! ```bash
74//! cargo run --example auth # Interactive login + token persistence
75//! cargo run --example account # Account details, ID lookup, friends, external auths, SSO
76//! cargo run --example entitlements # List all entitlements
77//! cargo run --example library # Paginated library listing
78//! cargo run --example assets # Full pipeline: list → info → manifest → download
79//! cargo run --example game_token # Exchange code + ownership token
80//! cargo run --example fab # Fab library → asset manifest → download manifest
81//! cargo run --example catalog # Catalog items, offers, bulk lookup
82//! cargo run --example commerce # Currencies, prices, billing, quick purchase
83//! cargo run --example status # Service status (lightswitch API)
84//! cargo run --example presence # Update online presence
85//! cargo run --example client_credentials # App-level auth + library state tokens
86//! ```
87
88use crate::api::types::account::{AccountData, AccountInfo, ExternalAuth, UserData};
89use crate::api::types::epic_asset::EpicAsset;
90use crate::api::types::fab_asset_manifest::DownloadInfo;
91use crate::api::types::friends::Friend;
92use crate::api::{EpicAPI};
93
94use api::types::asset_info::{AssetInfo, GameToken};
95use api::types::asset_manifest::AssetManifest;
96use api::types::artifact_service::ArtifactServiceTicket;
97use api::types::billing_account::BillingAccount;
98use api::types::catalog_item::CatalogItemPage;
99use api::types::catalog_offer::CatalogOfferPage;
100use api::types::cloud_save::CloudSaveResponse;
101use api::types::currency::CurrencyPage;
102use api::types::download_manifest::DownloadManifest;
103use api::types::entitlement::Entitlement;
104use api::types::library::Library;
105use api::types::presence::PresenceUpdate;
106use api::types::price::PriceResponse;
107use api::types::quick_purchase::QuickPurchaseResponse;
108use api::types::service_status::ServiceStatus;
109use api::types::uplay::{
110 UplayClaimResult, UplayCodesResult, UplayGraphQLResponse, UplayRedeemResult,
111};
112use api::types::cosmos;
113use api::types::engine_blob;
114use api::types::fab_search;
115use log::{error, info, warn};
116use crate::api::error::EpicAPIError;
117
118/// Module for authenticated API communication
119pub mod api;
120
121/// Client for the Epic Games Store API.
122///
123/// This is the main entry point for the library. Create an instance with
124/// [`EpicGames::new`], authenticate with [`EpicGames::auth_code`] or
125/// [`EpicGames::login`], then call API methods.
126///
127/// Most methods return `Option<T>` or `Vec<T>`, returning `None` / empty on
128/// errors. Fab methods return `Result<T, EpicAPIError>` for richer error
129/// handling (e.g., distinguishing timeouts from other failures).
130///
131/// Session state is stored in [`UserData`], which implements
132/// `Serialize` / `Deserialize` for persistence across runs.
133#[derive(Debug, Clone)]
134pub struct EpicGames {
135 egs: EpicAPI,
136}
137
138impl Default for EpicGames {
139 fn default() -> Self {
140 Self::new()
141 }
142}
143
144impl EpicGames {
145 /// Creates a new [`EpicGames`] client.
146 pub fn new() -> Self {
147 EpicGames {
148 egs: EpicAPI::new(),
149 }
150 }
151
152 /// Check whether the user is logged in.
153 ///
154 /// Returns `true` if the access token exists and has more than 600 seconds
155 /// remaining before expiry.
156 pub fn is_logged_in(&self) -> bool {
157 if let Some(exp) = self.egs.user_data.expires_at {
158 let now = chrono::offset::Utc::now();
159 let td = exp - now;
160 if td.num_seconds() > 600 {
161 return true;
162 }
163 }
164 false
165 }
166
167 /// Returns a clone of the current session state.
168 ///
169 /// The returned [`UserData`] implements `Serialize` / `Deserialize`,
170 /// so you can persist it to disk and restore it later with
171 /// [`set_user_details`](Self::set_user_details).
172 pub fn user_details(&self) -> UserData {
173 self.egs.user_data.clone()
174 }
175
176 /// Restore session state from a previously saved [`UserData`].
177 ///
178 /// Only merges `Some` fields — existing values are preserved for any
179 /// field that is `None` in the input. Call [`login`](Self::login)
180 /// afterward to refresh the access token.
181 pub fn set_user_details(&mut self, user_details: UserData) {
182 self.egs.user_data.update(user_details);
183 }
184
185 /// Like [`auth_code`](Self::auth_code), but returns a `Result` instead of swallowing errors.
186 pub async fn try_auth_code(
187 &mut self,
188 exchange_token: Option<String>,
189 authorization_code: Option<String>,
190 ) -> Result<bool, EpicAPIError> {
191 self.egs
192 .start_session(exchange_token, authorization_code)
193 .await
194 }
195
196 /// Authenticate with an authorization code or exchange token.
197 ///
198 /// Returns `true` on success, `false` on failure. Returns `None` on API errors.
199 pub async fn auth_code(
200 &mut self,
201 exchange_token: Option<String>,
202 authorization_code: Option<String>,
203 ) -> bool {
204 self.try_auth_code(exchange_token, authorization_code)
205 .await
206 .unwrap_or(false)
207 }
208
209 /// Invalidate the current session and log out.
210 pub async fn logout(&mut self) -> bool {
211 self.egs.invalidate_sesion().await
212 }
213
214 /// Like [`login`](Self::login), but returns a `Result` instead of swallowing errors.
215 pub async fn try_login(&mut self) -> Result<bool, EpicAPIError> {
216 if let Some(exp) = self.egs.user_data.expires_at {
217 let now = chrono::offset::Utc::now();
218 let td = exp - now;
219 if td.num_seconds() > 600 {
220 info!("Trying to re-use existing login session... ");
221 let resumed = self.egs.resume_session().await.map_err(|e| {
222 warn!("{}", e);
223 e
224 })?;
225 if resumed {
226 info!("Logged in");
227 return Ok(true);
228 }
229 return Ok(false);
230 }
231 }
232 info!("Logging in...");
233 if let Some(exp) = self.egs.user_data.refresh_expires_at {
234 let now = chrono::offset::Utc::now();
235 let td = exp - now;
236 if td.num_seconds() > 600 {
237 let started = self.egs.start_session(None, None).await.map_err(|e| {
238 error!("{}", e);
239 e
240 })?;
241 if started {
242 info!("Logged in");
243 return Ok(true);
244 }
245 return Ok(false);
246 }
247 }
248 Ok(false)
249 }
250
251 /// Resume session using the saved refresh token.
252 ///
253 /// Returns `true` on success, `false` if the refresh token has expired or is invalid.
254 /// Unlike [`try_login`](Self::try_login), this method falls through to
255 /// refresh-token login if session resume fails.
256 pub async fn login(&mut self) -> bool {
257 if let Some(exp) = self.egs.user_data.expires_at {
258 let now = chrono::offset::Utc::now();
259 let td = exp - now;
260 if td.num_seconds() > 600 {
261 info!("Trying to re-use existing login session... ");
262 match self.egs.resume_session().await {
263 Ok(b) => {
264 if b {
265 info!("Logged in");
266 return true;
267 }
268 return false;
269 }
270 Err(e) => {
271 warn!("{}", e)
272 }
273 };
274 }
275 }
276 info!("Logging in...");
277 if let Some(exp) = self.egs.user_data.refresh_expires_at {
278 let now = chrono::offset::Utc::now();
279 let td = exp - now;
280 if td.num_seconds() > 600 {
281 match self.egs.start_session(None, None).await {
282 Ok(b) => {
283 if b {
284 info!("Logged in");
285 return true;
286 }
287 return false;
288 }
289 Err(e) => {
290 error!("{}", e)
291 }
292 }
293 }
294 }
295 false
296 }
297
298 /// Like [`list_assets`](Self::list_assets), but returns a `Result` instead of swallowing errors.
299 pub async fn try_list_assets(
300 &mut self,
301 platform: Option<String>,
302 label: Option<String>,
303 ) -> Result<Vec<EpicAsset>, EpicAPIError> {
304 self.egs.assets(platform, label).await
305 }
306
307 /// List all owned assets.
308 ///
309 /// Defaults to platform="Windows" and label="Live" if not specified.
310 /// Returns empty `Vec` on API errors.
311 pub async fn list_assets(
312 &mut self,
313 platform: Option<String>,
314 label: Option<String>,
315 ) -> Vec<EpicAsset> {
316 self.try_list_assets(platform, label)
317 .await
318 .unwrap_or_else(|_| Vec::new())
319 }
320
321 /// Like [`asset_manifest`](Self::asset_manifest), but returns a `Result` instead of swallowing errors.
322 pub async fn try_asset_manifest(
323 &mut self,
324 platform: Option<String>,
325 label: Option<String>,
326 namespace: Option<String>,
327 item_id: Option<String>,
328 app: Option<String>,
329 ) -> Result<AssetManifest, EpicAPIError> {
330 self.egs
331 .asset_manifest(platform, label, namespace, item_id, app)
332 .await
333 }
334
335 /// Fetch asset manifest with CDN download URLs.
336 ///
337 /// Defaults to platform="Windows" and label="Live" if not specified.
338 /// Returns `None` on API errors.
339 pub async fn asset_manifest(
340 &mut self,
341 platform: Option<String>,
342 label: Option<String>,
343 namespace: Option<String>,
344 item_id: Option<String>,
345 app: Option<String>,
346 ) -> Option<AssetManifest> {
347 self.try_asset_manifest(platform, label, namespace, item_id, app)
348 .await
349 .ok()
350 }
351
352 /// Fetch Fab asset manifest with signed distribution points.
353 ///
354 /// Returns `Result` to expose timeout errors (403 → `EpicAPIError::FabTimeout`).
355 pub async fn fab_asset_manifest(
356 &self,
357 artifact_id: &str,
358 namespace: &str,
359 asset_id: &str,
360 platform: Option<&str>,
361 ) -> Result<Vec<DownloadInfo>, EpicAPIError> {
362 match self
363 .egs
364 .fab_asset_manifest(artifact_id, namespace, asset_id, platform)
365 .await
366 {
367 Ok(a) => Ok(a),
368 Err(e) => Err(e),
369 }
370 }
371
372 /// Like [`asset_info`](Self::asset_info), but returns a `Result` instead of swallowing errors.
373 pub async fn try_asset_info(
374 &mut self,
375 asset: &EpicAsset,
376 ) -> Result<Option<AssetInfo>, EpicAPIError> {
377 let mut info = self.egs.asset_info(asset).await?;
378 Ok(info.remove(asset.catalog_item_id.as_str()))
379 }
380
381 /// Fetch catalog metadata for an asset (includes DLC tree).
382 ///
383 /// Returns `None` on API errors.
384 pub async fn asset_info(&mut self, asset: &EpicAsset) -> Option<AssetInfo> {
385 self.try_asset_info(asset).await.ok().flatten()
386 }
387
388 /// Like [`account_details`](Self::account_details), but returns a `Result` instead of swallowing errors.
389 pub async fn try_account_details(&mut self) -> Result<AccountData, EpicAPIError> {
390 self.egs.account_details().await
391 }
392
393 /// Fetch account details (email, display name, country, 2FA status).
394 ///
395 /// Returns `None` on API errors.
396 pub async fn account_details(&mut self) -> Option<AccountData> {
397 self.try_account_details().await.ok()
398 }
399
400 /// Like [`account_ids_details`](Self::account_ids_details), but returns a `Result` instead of swallowing errors.
401 pub async fn try_account_ids_details(
402 &mut self,
403 ids: Vec<String>,
404 ) -> Result<Vec<AccountInfo>, EpicAPIError> {
405 self.egs.account_ids_details(ids).await
406 }
407
408 /// Bulk lookup of account IDs to display names.
409 ///
410 /// Returns `None` on API errors.
411 pub async fn account_ids_details(&mut self, ids: Vec<String>) -> Option<Vec<AccountInfo>> {
412 self.try_account_ids_details(ids).await.ok()
413 }
414
415 /// Like [`account_friends`](Self::account_friends), but returns a `Result` instead of swallowing errors.
416 pub async fn try_account_friends(
417 &mut self,
418 include_pending: bool,
419 ) -> Result<Vec<Friend>, EpicAPIError> {
420 self.egs.account_friends(include_pending).await
421 }
422
423 /// Fetch friends list (including pending requests if `include_pending` is true).
424 ///
425 /// Returns `None` on API errors.
426 pub async fn account_friends(&mut self, include_pending: bool) -> Option<Vec<Friend>> {
427 self.try_account_friends(include_pending).await.ok()
428 }
429
430 /// Like [`game_token`](Self::game_token), but returns a `Result` instead of swallowing errors.
431 pub async fn try_game_token(&mut self) -> Result<GameToken, EpicAPIError> {
432 self.egs.game_token().await
433 }
434
435 /// Fetch a short-lived exchange code for game launches.
436 ///
437 /// Returns `None` on API errors.
438 pub async fn game_token(&mut self) -> Option<GameToken> {
439 self.try_game_token().await.ok()
440 }
441
442 /// Like [`ownership_token`](Self::ownership_token), but returns a `Result` instead of swallowing errors.
443 pub async fn try_ownership_token(&mut self, asset: &EpicAsset) -> Result<String, EpicAPIError> {
444 self.egs.ownership_token(asset).await.map(|a| a.token)
445 }
446
447 /// Fetch a JWT proving ownership of an asset.
448 ///
449 /// Returns `None` on API errors.
450 pub async fn ownership_token(&mut self, asset: &EpicAsset) -> Option<String> {
451 self.try_ownership_token(asset).await.ok()
452 }
453
454 /// Like [`user_entitlements`](Self::user_entitlements), but returns a `Result` instead of swallowing errors.
455 pub async fn try_user_entitlements(&mut self) -> Result<Vec<Entitlement>, EpicAPIError> {
456 self.egs.user_entitlements().await
457 }
458
459 /// Fetch all user entitlements (games, DLC, subscriptions).
460 ///
461 /// Returns empty `Vec` on API errors.
462 pub async fn user_entitlements(&mut self) -> Vec<Entitlement> {
463 self.try_user_entitlements().await.unwrap_or_else(|_| Vec::new())
464 }
465
466 /// Like [`library_items`](Self::library_items), but returns a `Result` instead of swallowing errors.
467 pub async fn try_library_items(&mut self, include_metadata: bool) -> Result<Library, EpicAPIError> {
468 self.egs.library_items(include_metadata).await
469 }
470
471 /// Fetch the user library with optional metadata.
472 ///
473 /// Paginates internally and returns all records at once. Returns `None` on API errors.
474 pub async fn library_items(&mut self, include_metadata: bool) -> Option<Library> {
475 self.try_library_items(include_metadata).await.ok()
476 }
477
478 /// Like [`fab_library_items`](Self::fab_library_items), but returns a `Result` instead of swallowing errors.
479 pub async fn try_fab_library_items(
480 &mut self,
481 account_id: String,
482 ) -> Result<api::types::fab_library::FabLibrary, EpicAPIError> {
483 self.egs.fab_library_items(account_id).await
484 }
485
486 /// Fetch the user Fab library.
487 ///
488 /// Paginates internally and returns all records at once. Returns `None` on API errors.
489 pub async fn fab_library_items(
490 &mut self,
491 account_id: String,
492 ) -> Option<api::types::fab_library::FabLibrary> {
493 self.try_fab_library_items(account_id).await.ok()
494 }
495
496 /// Parse download manifests from all CDN mirrors.
497 ///
498 /// Fetches from all mirrors, parses binary/JSON format, and populates custom fields
499 /// (BaseUrl, CatalogItemId, etc.). Returns empty `Vec` on API errors.
500 pub async fn asset_download_manifests(&self, manifest: AssetManifest) -> Vec<DownloadManifest> {
501 self.egs.asset_download_manifests(manifest).await
502 }
503
504 /// Parse a Fab download manifest from a specific distribution point.
505 ///
506 /// Checks signature expiration before fetching. Returns `Result` to expose timeout errors.
507 pub async fn fab_download_manifest(
508 &self,
509 download_info: DownloadInfo,
510 distribution_point_url: &str,
511 ) -> Result<DownloadManifest, EpicAPIError> {
512 self.egs
513 .fab_download_manifest(download_info, distribution_point_url)
514 .await
515 }
516
517 /// Like [`auth_client_credentials`](Self::auth_client_credentials), but returns a `Result` instead of swallowing errors.
518 pub async fn try_auth_client_credentials(&mut self) -> Result<bool, EpicAPIError> {
519 self.egs.start_client_credentials_session().await
520 }
521
522 /// Authenticate with client credentials (app-level, no user context).
523 ///
524 /// Uses the launcher's public client ID/secret to obtain an access token
525 /// without any user interaction. The resulting session has limited
526 /// permissions — it can query public endpoints (catalog, service status,
527 /// currencies) but cannot access user-specific data (library, entitlements).
528 ///
529 /// Returns `true` on success, `false` on failure.
530 pub async fn auth_client_credentials(&mut self) -> bool {
531 self.try_auth_client_credentials().await.unwrap_or(false)
532 }
533
534 /// Like [`external_auths`](Self::external_auths), but returns a `Result` instead of swallowing errors.
535 pub async fn try_external_auths(&self, account_id: &str) -> Result<Vec<ExternalAuth>, EpicAPIError> {
536 self.egs.external_auths(account_id).await
537 }
538
539 /// Fetch external auth connections linked to an account.
540 ///
541 /// Returns linked platform accounts (Steam, PSN, Xbox, Nintendo, etc.)
542 /// with external display names and account IDs. Requires a valid user session.
543 ///
544 /// Returns `None` on API errors.
545 pub async fn external_auths(&self, account_id: &str) -> Option<Vec<ExternalAuth>> {
546 self.try_external_auths(account_id).await.ok()
547 }
548
549 /// Like [`sso_domains`](Self::sso_domains), but returns a `Result` instead of swallowing errors.
550 pub async fn try_sso_domains(&self) -> Result<Vec<String>, EpicAPIError> {
551 self.egs.sso_domains().await
552 }
553
554 /// Fetch the list of SSO (Single Sign-On) domains.
555 ///
556 /// Returns domain strings that support Epic's SSO flow. Used by the
557 /// launcher to determine which domains can share authentication cookies.
558 ///
559 /// Returns `None` on API errors.
560 pub async fn sso_domains(&self) -> Option<Vec<String>> {
561 self.try_sso_domains().await.ok()
562 }
563
564 /// Like [`catalog_items`](Self::catalog_items), but returns a `Result` instead of swallowing errors.
565 pub async fn try_catalog_items(
566 &self,
567 namespace: &str,
568 start: i64,
569 count: i64,
570 ) -> Result<CatalogItemPage, EpicAPIError> {
571 self.egs.catalog_items(namespace, start, count).await
572 }
573
574 /// Fetch paginated catalog items for a namespace.
575 ///
576 /// Queries the Epic catalog service for items within a given namespace
577 /// (e.g., a game's namespace). Results are paginated — use `start` and
578 /// `count` to page through. Each [`CatalogItemPage`] includes a `paging`
579 /// field with the total count.
580 ///
581 /// Returns `None` on API errors.
582 pub async fn catalog_items(
583 &self,
584 namespace: &str,
585 start: i64,
586 count: i64,
587 ) -> Option<CatalogItemPage> {
588 self.try_catalog_items(namespace, start, count).await.ok()
589 }
590
591 /// Like [`catalog_offers`](Self::catalog_offers), but returns a `Result` instead of swallowing errors.
592 pub async fn try_catalog_offers(
593 &self,
594 namespace: &str,
595 start: i64,
596 count: i64,
597 ) -> Result<CatalogOfferPage, EpicAPIError> {
598 self.egs.catalog_offers(namespace, start, count).await
599 }
600
601 /// Fetch paginated catalog offers for a namespace.
602 ///
603 /// Queries the Epic catalog service for offers (purchasable items) within
604 /// a namespace. Offers include pricing metadata, seller info, and linked
605 /// catalog items. Use `start` and `count` to paginate.
606 ///
607 /// Returns `None` on API errors.
608 pub async fn catalog_offers(
609 &self,
610 namespace: &str,
611 start: i64,
612 count: i64,
613 ) -> Option<CatalogOfferPage> {
614 self.try_catalog_offers(namespace, start, count).await.ok()
615 }
616
617 /// Like [`bulk_catalog_items`](Self::bulk_catalog_items), but returns a `Result` instead of swallowing errors.
618 pub async fn try_bulk_catalog_items(
619 &self,
620 items: &[(&str, &str)],
621 ) -> Result<std::collections::HashMap<String, std::collections::HashMap<String, AssetInfo>>, EpicAPIError> {
622 self.egs.bulk_catalog_items(items).await
623 }
624
625 /// Bulk fetch catalog items across multiple namespaces.
626 ///
627 /// Accepts a slice of `(namespace, item_id)` pairs and returns them grouped
628 /// by namespace → item_id → [`AssetInfo`]. Useful for resolving catalog
629 /// metadata for items from different games in a single request.
630 ///
631 /// Returns `None` on API errors.
632 pub async fn bulk_catalog_items(
633 &self,
634 items: &[(&str, &str)],
635 ) -> Option<std::collections::HashMap<String, std::collections::HashMap<String, AssetInfo>>> {
636 self.try_bulk_catalog_items(items).await.ok()
637 }
638
639 /// Like [`currencies`](Self::currencies), but returns a `Result` instead of swallowing errors.
640 pub async fn try_currencies(&self, start: i64, count: i64) -> Result<CurrencyPage, EpicAPIError> {
641 self.egs.currencies(start, count).await
642 }
643
644 /// Fetch available currencies from the Epic catalog.
645 ///
646 /// Returns paginated currency definitions including code, symbol, and
647 /// decimal precision. Use `start` and `count` to paginate.
648 ///
649 /// Returns `None` on API errors.
650 pub async fn currencies(&self, start: i64, count: i64) -> Option<CurrencyPage> {
651 self.try_currencies(start, count).await.ok()
652 }
653
654 /// Like [`library_state_token_status`](Self::library_state_token_status), but returns a `Result` instead of swallowing errors.
655 pub async fn try_library_state_token_status(
656 &self,
657 token_id: &str,
658 ) -> Result<bool, EpicAPIError> {
659 self.egs.library_state_token_status(token_id).await
660 }
661
662 /// Check the validity of a library state token.
663 ///
664 /// Returns `Some(true)` if the token is still valid, `Some(false)` if
665 /// expired or invalid, or `None` on API errors. Library state tokens are
666 /// used to detect changes to the user's library since the last sync.
667 ///
668 /// Returns `None` on API errors.
669 pub async fn library_state_token_status(&self, token_id: &str) -> Option<bool> {
670 self.try_library_state_token_status(token_id).await.ok()
671 }
672
673 /// Like [`service_status`](Self::service_status), but returns a `Result` instead of swallowing errors.
674 pub async fn try_service_status(
675 &self,
676 service_id: &str,
677 ) -> Result<Vec<ServiceStatus>, EpicAPIError> {
678 self.egs.service_status(service_id).await
679 }
680
681 /// Fetch service status from Epic's lightswitch API.
682 ///
683 /// Returns the operational status of an Epic online service (e.g., a game's
684 /// backend). The response includes whether the service is UP/DOWN, any
685 /// maintenance message, and whether the current user is banned.
686 ///
687 /// Returns `None` on API errors.
688 pub async fn service_status(&self, service_id: &str) -> Option<Vec<ServiceStatus>> {
689 self.try_service_status(service_id).await.ok()
690 }
691
692 /// Like [`offer_prices`](Self::offer_prices), but returns a `Result` instead of swallowing errors.
693 pub async fn try_offer_prices(
694 &self,
695 namespace: &str,
696 offer_ids: &[String],
697 country: &str,
698 ) -> Result<PriceResponse, EpicAPIError> {
699 self.egs.offer_prices(namespace, offer_ids, country).await
700 }
701
702 /// Fetch offer prices from Epic's price engine.
703 ///
704 /// Queries current pricing for one or more offers within a namespace,
705 /// localized to a specific country. The response includes original price,
706 /// discount price, and pre-formatted display strings.
707 ///
708 /// Returns `None` on API errors.
709 pub async fn offer_prices(
710 &self,
711 namespace: &str,
712 offer_ids: &[String],
713 country: &str,
714 ) -> Option<PriceResponse> {
715 self.try_offer_prices(namespace, offer_ids, country).await.ok()
716 }
717
718 /// Like [`quick_purchase`](Self::quick_purchase), but returns a `Result` instead of swallowing errors.
719 pub async fn try_quick_purchase(
720 &self,
721 namespace: &str,
722 offer_id: &str,
723 ) -> Result<QuickPurchaseResponse, EpicAPIError> {
724 self.egs.quick_purchase(namespace, offer_id).await
725 }
726
727 /// Execute a quick purchase (typically for free game claims).
728 ///
729 /// Initiates a purchase order for a free offer. The response contains the
730 /// order ID and its processing status. For paid offers, use the full
731 /// checkout flow in the Epic Games launcher instead.
732 ///
733 /// Returns `None` on API errors.
734 pub async fn quick_purchase(
735 &self,
736 namespace: &str,
737 offer_id: &str,
738 ) -> Option<QuickPurchaseResponse> {
739 self.try_quick_purchase(namespace, offer_id).await.ok()
740 }
741
742 /// Like [`billing_account`](Self::billing_account), but returns a `Result` instead of swallowing errors.
743 pub async fn try_billing_account(&self) -> Result<BillingAccount, EpicAPIError> {
744 self.egs.billing_account().await
745 }
746
747 /// Fetch the default billing account for payment processing.
748 ///
749 /// Returns the account's billing country, which is used to determine
750 /// regional pricing and payment availability.
751 ///
752 /// Returns `None` on API errors.
753 pub async fn billing_account(&self) -> Option<BillingAccount> {
754 self.try_billing_account().await.ok()
755 }
756
757 /// Update the user's presence status.
758 ///
759 /// Sends a PATCH request to update the user's online presence (e.g.,
760 /// "online", "away") and optionally set an activity with custom properties.
761 /// The `session_id` is the OAuth session token from login. Returns `Ok(())`
762 /// on success (204 No Content) or an [`EpicAPIError`] on failure.
763 pub async fn update_presence(
764 &self,
765 session_id: &str,
766 body: &PresenceUpdate,
767 ) -> Result<(), EpicAPIError> {
768 self.egs.update_presence(session_id, body).await
769 }
770
771 /// Like [`fab_file_download_info`](Self::fab_file_download_info), but returns a `Result` instead of swallowing errors.
772 pub async fn try_fab_file_download_info(
773 &self,
774 listing_id: &str,
775 format_id: &str,
776 file_id: &str,
777 ) -> Result<DownloadInfo, EpicAPIError> {
778 self.egs
779 .fab_file_download_info(listing_id, format_id, file_id)
780 .await
781 }
782
783 /// Fetch download info for a specific file within a Fab listing.
784 ///
785 /// Returns signed [`DownloadInfo`] for a single file identified by
786 /// `listing_id`, `format_id`, and `file_id`. Use this for targeted
787 /// downloads of individual files from a Fab asset rather than fetching
788 /// the entire asset manifest.
789 ///
790 /// Returns `None` on API errors.
791 pub async fn fab_file_download_info(
792 &self,
793 listing_id: &str,
794 format_id: &str,
795 file_id: &str,
796 ) -> Option<DownloadInfo> {
797 self.try_fab_file_download_info(listing_id, format_id, file_id)
798 .await
799 .ok()
800 }
801
802 // ── Cloud Saves ──
803
804 /// List cloud save files for the logged-in user.
805 ///
806 /// If `app_name` is provided, lists saves for that specific game.
807 /// If `manifests` is true (only relevant when `app_name` is set), lists manifest files.
808 pub async fn cloud_save_list(
809 &self,
810 app_name: Option<&str>,
811 manifests: bool,
812 ) -> Result<CloudSaveResponse, EpicAPIError> {
813 self.egs.cloud_save_list(app_name, manifests).await
814 }
815
816 /// Query cloud save files by specific filenames.
817 ///
818 /// Returns metadata including read/write links for the specified files.
819 pub async fn cloud_save_query(
820 &self,
821 app_name: &str,
822 filenames: &[String],
823 ) -> Result<CloudSaveResponse, EpicAPIError> {
824 self.egs.cloud_save_query(app_name, filenames).await
825 }
826
827 /// Delete a cloud save file by its storage path.
828 pub async fn cloud_save_delete(&self, path: &str) -> Result<(), EpicAPIError> {
829 self.egs.cloud_save_delete(path).await
830 }
831
832 // ── Artifact Service & Manifests ──
833
834 /// Fetch an artifact service ticket for manifest retrieval via EOS Helper.
835 ///
836 /// The `sandbox_id` is typically the game's namespace and `artifact_id`
837 /// is the app name. Returns a signed ticket for use with
838 /// [`game_manifest_by_ticket`](Self::game_manifest_by_ticket).
839 pub async fn artifact_service_ticket(
840 &self,
841 sandbox_id: &str,
842 artifact_id: &str,
843 label: Option<&str>,
844 platform: Option<&str>,
845 ) -> Result<ArtifactServiceTicket, EpicAPIError> {
846 self.egs
847 .artifact_service_ticket(sandbox_id, artifact_id, label, platform)
848 .await
849 }
850
851 /// Fetch a game manifest using a signed artifact service ticket.
852 ///
853 /// Alternative to [`asset_manifest`](Self::asset_manifest) using ticket-based
854 /// auth from the EOS Helper service.
855 pub async fn game_manifest_by_ticket(
856 &self,
857 artifact_id: &str,
858 signed_ticket: &str,
859 label: Option<&str>,
860 platform: Option<&str>,
861 ) -> Result<AssetManifest, EpicAPIError> {
862 self.egs
863 .game_manifest_by_ticket(artifact_id, signed_ticket, label, platform)
864 .await
865 }
866
867 /// Fetch launcher manifests for self-update checks.
868 pub async fn launcher_manifests(
869 &self,
870 platform: Option<&str>,
871 label: Option<&str>,
872 ) -> Result<AssetManifest, EpicAPIError> {
873 self.egs.launcher_manifests(platform, label).await
874 }
875
876 /// Try to fetch a delta manifest for optimized patching between builds.
877 ///
878 /// Returns `None` if no delta is available or the builds are identical.
879 pub async fn delta_manifest(
880 &self,
881 base_url: &str,
882 old_build_id: &str,
883 new_build_id: &str,
884 ) -> Option<Vec<u8>> {
885 self.egs
886 .delta_manifest(base_url, old_build_id, new_build_id)
887 .await
888 }
889
890 // ── SID Auth ──
891
892 /// Authenticate via session ID (SID) from the Epic web login flow.
893 ///
894 /// Performs the multi-step web exchange: set-sid → CSRF → exchange code,
895 /// then starts a session with the resulting code. Returns `true` on success.
896 pub async fn auth_sid(&mut self, sid: &str) -> Result<bool, EpicAPIError> {
897 self.egs.auth_sid(sid).await
898 }
899
900 // ── Uplay / Ubisoft Store ──
901
902 /// Fetch Uplay codes linked to the user's Epic account.
903 pub async fn store_get_uplay_codes(
904 &self,
905 ) -> Result<UplayGraphQLResponse<UplayCodesResult>, EpicAPIError> {
906 self.egs.store_get_uplay_codes().await
907 }
908
909 /// Claim a Uplay code for a specific game.
910 pub async fn store_claim_uplay_code(
911 &self,
912 uplay_account_id: &str,
913 game_id: &str,
914 ) -> Result<UplayGraphQLResponse<UplayClaimResult>, EpicAPIError> {
915 self.egs
916 .store_claim_uplay_code(uplay_account_id, game_id)
917 .await
918 }
919
920 /// Redeem all pending Uplay codes for the user's account.
921 pub async fn store_redeem_uplay_codes(
922 &self,
923 uplay_account_id: &str,
924 ) -> Result<UplayGraphQLResponse<UplayRedeemResult>, EpicAPIError> {
925 self.egs.store_redeem_uplay_codes(uplay_account_id).await
926 }
927
928 // --- Cosmos Session ---
929
930 /// Set up a Cosmos cookie session from an exchange code.
931 /// Typically called with a code from `game_token()`.
932 pub async fn cosmos_session_setup(
933 &self,
934 exchange_code: &str,
935 ) -> Result<cosmos::CosmosAuthResponse, EpicAPIError> {
936 self.egs.cosmos_session_setup(exchange_code).await
937 }
938
939 /// Upgrade bearer token to Cosmos session (step 5 of session setup).
940 pub async fn cosmos_auth_upgrade(
941 &self,
942 ) -> Result<cosmos::CosmosAuthResponse, EpicAPIError> {
943 self.egs.cosmos_auth_upgrade().await
944 }
945
946 /// Check if a EULA has been accepted. Returns `None` on error.
947 pub async fn cosmos_eula_check(&self, eula_id: &str, locale: &str) -> Option<bool> {
948 self.egs
949 .cosmos_eula_check(eula_id, locale)
950 .await
951 .ok()
952 .map(|r| r.accepted)
953 }
954
955 /// Check if a EULA has been accepted. Returns full `Result`.
956 pub async fn try_cosmos_eula_check(
957 &self,
958 eula_id: &str,
959 locale: &str,
960 ) -> Result<cosmos::CosmosEulaResponse, EpicAPIError> {
961 self.egs.cosmos_eula_check(eula_id, locale).await
962 }
963
964 /// Accept a EULA. Returns `None` on error.
965 pub async fn cosmos_eula_accept(
966 &self,
967 eula_id: &str,
968 locale: &str,
969 version: u32,
970 ) -> Option<bool> {
971 self.egs
972 .cosmos_eula_accept(eula_id, locale, version)
973 .await
974 .ok()
975 .map(|r| r.accepted)
976 }
977
978 /// Accept a EULA. Returns full `Result`.
979 pub async fn try_cosmos_eula_accept(
980 &self,
981 eula_id: &str,
982 locale: &str,
983 version: u32,
984 ) -> Result<cosmos::CosmosEulaResponse, EpicAPIError> {
985 self.egs.cosmos_eula_accept(eula_id, locale, version).await
986 }
987
988 /// Get Cosmos account details. Returns `None` on error.
989 pub async fn cosmos_account(&self) -> Option<cosmos::CosmosAccount> {
990 self.egs.cosmos_account().await.ok()
991 }
992
993 /// Get Cosmos account details. Returns full `Result`.
994 pub async fn try_cosmos_account(&self) -> Result<cosmos::CosmosAccount, EpicAPIError> {
995 self.egs.cosmos_account().await
996 }
997
998 /// Fetch engine version download blobs for a platform. Returns `None` on error.
999 pub async fn engine_versions(
1000 &self,
1001 platform: &str,
1002 ) -> Option<engine_blob::EngineBlobsResponse> {
1003 self.egs.engine_versions(platform).await.ok()
1004 }
1005
1006 /// Fetch engine version download blobs. Returns full `Result`.
1007 pub async fn try_engine_versions(
1008 &self,
1009 platform: &str,
1010 ) -> Result<engine_blob::EngineBlobsResponse, EpicAPIError> {
1011 self.egs.engine_versions(platform).await
1012 }
1013
1014 // --- Fab Search/Browse ---
1015
1016 /// Search Fab listings. Returns `None` on error.
1017 pub async fn fab_search(
1018 &self,
1019 params: &fab_search::FabSearchParams,
1020 ) -> Option<fab_search::FabSearchResults> {
1021 self.egs.fab_search(params).await.ok()
1022 }
1023
1024 /// Search Fab listings. Returns full `Result`.
1025 pub async fn try_fab_search(
1026 &self,
1027 params: &fab_search::FabSearchParams,
1028 ) -> Result<fab_search::FabSearchResults, EpicAPIError> {
1029 self.egs.fab_search(params).await
1030 }
1031
1032 /// Get full listing detail. Returns `None` on error.
1033 pub async fn fab_listing(&self, uid: &str) -> Option<fab_search::FabListingDetail> {
1034 self.egs.fab_listing(uid).await.ok()
1035 }
1036
1037 /// Get full listing detail. Returns full `Result`.
1038 pub async fn try_fab_listing(
1039 &self,
1040 uid: &str,
1041 ) -> Result<fab_search::FabListingDetail, EpicAPIError> {
1042 self.egs.fab_listing(uid).await
1043 }
1044
1045 /// Get UE-specific format details for a listing. Returns `None` on error.
1046 pub async fn fab_listing_ue_formats(
1047 &self,
1048 uid: &str,
1049 ) -> Option<Vec<fab_search::FabListingUeFormat>> {
1050 self.egs.fab_listing_ue_formats(uid).await.ok()
1051 }
1052
1053 /// Get UE-specific format details. Returns full `Result`.
1054 pub async fn try_fab_listing_ue_formats(
1055 &self,
1056 uid: &str,
1057 ) -> Result<Vec<fab_search::FabListingUeFormat>, EpicAPIError> {
1058 self.egs.fab_listing_ue_formats(uid).await
1059 }
1060
1061 /// Get listing state (ownership, wishlist, review). Returns `None` on error.
1062 pub async fn fab_listing_state(
1063 &self,
1064 uid: &str,
1065 ) -> Option<fab_search::FabListingState> {
1066 self.egs.fab_listing_state(uid).await.ok()
1067 }
1068
1069 /// Get listing state. Returns full `Result`.
1070 pub async fn try_fab_listing_state(
1071 &self,
1072 uid: &str,
1073 ) -> Result<fab_search::FabListingState, EpicAPIError> {
1074 self.egs.fab_listing_state(uid).await
1075 }
1076
1077 /// Bulk check listing states. Returns `None` on error.
1078 pub async fn fab_listing_states_bulk(
1079 &self,
1080 listing_ids: &[&str],
1081 ) -> Option<Vec<fab_search::FabListingState>> {
1082 self.egs.fab_listing_states_bulk(listing_ids).await.ok()
1083 }
1084
1085 /// Bulk check listing states. Returns full `Result`.
1086 pub async fn try_fab_listing_states_bulk(
1087 &self,
1088 listing_ids: &[&str],
1089 ) -> Result<Vec<fab_search::FabListingState>, EpicAPIError> {
1090 self.egs.fab_listing_states_bulk(listing_ids).await
1091 }
1092
1093 /// Bulk fetch pricing for multiple offer IDs. Returns `None` on error.
1094 pub async fn fab_bulk_prices(
1095 &self,
1096 offer_ids: &[&str],
1097 ) -> Option<Vec<fab_search::FabPriceInfo>> {
1098 self.egs.fab_bulk_prices(offer_ids).await.ok()
1099 }
1100
1101 /// Bulk fetch pricing. Returns full `Result`.
1102 pub async fn try_fab_bulk_prices(
1103 &self,
1104 offer_ids: &[&str],
1105 ) -> Result<Vec<fab_search::FabPriceInfo>, EpicAPIError> {
1106 self.egs.fab_bulk_prices(offer_ids).await
1107 }
1108
1109 /// Get listing ownership info. Returns `None` on error.
1110 pub async fn fab_listing_ownership(
1111 &self,
1112 uid: &str,
1113 ) -> Option<fab_search::FabOwnership> {
1114 self.egs.fab_listing_ownership(uid).await.ok()
1115 }
1116
1117 /// Get listing ownership info. Returns full `Result`.
1118 pub async fn try_fab_listing_ownership(
1119 &self,
1120 uid: &str,
1121 ) -> Result<fab_search::FabOwnership, EpicAPIError> {
1122 self.egs.fab_listing_ownership(uid).await
1123 }
1124
1125 // --- Cosmos Policy/Communication ---
1126
1127 /// Check Age of Digital Consent policy. Returns `None` on error.
1128 pub async fn cosmos_policy_aodc(&self) -> Option<cosmos::CosmosPolicyAodc> {
1129 self.egs.cosmos_policy_aodc().await.ok()
1130 }
1131
1132 /// Check Age of Digital Consent policy. Returns full `Result`.
1133 pub async fn try_cosmos_policy_aodc(
1134 &self,
1135 ) -> Result<cosmos::CosmosPolicyAodc, EpicAPIError> {
1136 self.egs.cosmos_policy_aodc().await
1137 }
1138
1139 /// Check communication opt-in status. Returns `None` on error.
1140 pub async fn cosmos_comm_opt_in(
1141 &self,
1142 setting: &str,
1143 ) -> Option<cosmos::CosmosCommOptIn> {
1144 self.egs.cosmos_comm_opt_in(setting).await.ok()
1145 }
1146
1147 /// Check communication opt-in status. Returns full `Result`.
1148 pub async fn try_cosmos_comm_opt_in(
1149 &self,
1150 setting: &str,
1151 ) -> Result<cosmos::CosmosCommOptIn, EpicAPIError> {
1152 self.egs.cosmos_comm_opt_in(setting).await
1153 }
1154}
1155
1156#[cfg(test)]
1157mod facade_tests {
1158 use super::*;
1159 use crate::api::types::account::UserData;
1160 use chrono::{Duration, Utc};
1161
1162 #[test]
1163 fn new_creates_instance() {
1164 let egs = EpicGames::new();
1165 assert!(!egs.is_logged_in());
1166 }
1167
1168 #[test]
1169 fn default_same_as_new() {
1170 let egs = EpicGames::default();
1171 assert!(!egs.is_logged_in());
1172 }
1173
1174 #[test]
1175 fn user_details_default_empty() {
1176 let egs = EpicGames::new();
1177 assert!(egs.user_details().access_token.is_none());
1178 }
1179
1180 #[test]
1181 fn set_and_get_user_details() {
1182 let mut egs = EpicGames::new();
1183 let mut ud = UserData::new();
1184 ud.display_name = Some("TestUser".to_string());
1185 egs.set_user_details(ud);
1186 assert_eq!(egs.user_details().display_name, Some("TestUser".to_string()));
1187 }
1188
1189 #[test]
1190 fn is_logged_in_expired() {
1191 let mut egs = EpicGames::new();
1192 let mut ud = UserData::new();
1193 ud.expires_at = Some(Utc::now() - Duration::hours(1));
1194 egs.set_user_details(ud);
1195 assert!(!egs.is_logged_in());
1196 }
1197
1198 #[test]
1199 fn is_logged_in_valid() {
1200 let mut egs = EpicGames::new();
1201 let mut ud = UserData::new();
1202 ud.expires_at = Some(Utc::now() + Duration::hours(2));
1203 egs.set_user_details(ud);
1204 assert!(egs.is_logged_in());
1205 }
1206
1207 #[test]
1208 fn is_logged_in_within_600s_threshold() {
1209 let mut egs = EpicGames::new();
1210 let mut ud = UserData::new();
1211 ud.expires_at = Some(Utc::now() + Duration::seconds(500));
1212 egs.set_user_details(ud);
1213 assert!(!egs.is_logged_in());
1214 }
1215}