Skip to main content

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 log::{error, info, warn};
113use crate::api::error::EpicAPIError;
114
115/// Module for authenticated API communication
116pub mod api;
117
118/// Client for the Epic Games Store API.
119///
120/// This is the main entry point for the library. Create an instance with
121/// [`EpicGames::new`], authenticate with [`EpicGames::auth_code`] or
122/// [`EpicGames::login`], then call API methods.
123///
124/// Most methods return `Option<T>` or `Vec<T>`, returning `None` / empty on
125/// errors. Fab methods return `Result<T, EpicAPIError>` for richer error
126/// handling (e.g., distinguishing timeouts from other failures).
127///
128/// Session state is stored in [`UserData`], which implements
129/// `Serialize` / `Deserialize` for persistence across runs.
130#[derive(Debug, Clone)]
131pub struct EpicGames {
132    egs: EpicAPI,
133}
134
135impl Default for EpicGames {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141impl EpicGames {
142    /// Creates a new [`EpicGames`] client.
143    pub fn new() -> Self {
144        EpicGames {
145            egs: EpicAPI::new(),
146        }
147    }
148
149    /// Check whether the user is logged in.
150    ///
151    /// Returns `true` if the access token exists and has more than 600 seconds
152    /// remaining before expiry.
153    pub fn is_logged_in(&self) -> bool {
154        if let Some(exp) = self.egs.user_data.expires_at {
155            let now = chrono::offset::Utc::now();
156            let td = exp - now;
157            if td.num_seconds() > 600 {
158                return true;
159            }
160        }
161        false
162    }
163
164    /// Returns a clone of the current session state.
165    ///
166    /// The returned [`UserData`] implements `Serialize` / `Deserialize`,
167    /// so you can persist it to disk and restore it later with
168    /// [`set_user_details`](Self::set_user_details).
169    pub fn user_details(&self) -> UserData {
170        self.egs.user_data.clone()
171    }
172
173    /// Restore session state from a previously saved [`UserData`].
174    ///
175    /// Only merges `Some` fields — existing values are preserved for any
176    /// field that is `None` in the input. Call [`login`](Self::login)
177    /// afterward to refresh the access token.
178    pub fn set_user_details(&mut self, user_details: UserData) {
179        self.egs.user_data.update(user_details);
180    }
181
182    /// Like [`auth_code`](Self::auth_code), but returns a `Result` instead of swallowing errors.
183    pub async fn try_auth_code(
184        &mut self,
185        exchange_token: Option<String>,
186        authorization_code: Option<String>,
187    ) -> Result<bool, EpicAPIError> {
188        self.egs
189            .start_session(exchange_token, authorization_code)
190            .await
191    }
192
193    /// Authenticate with an authorization code or exchange token.
194    ///
195    /// Returns `true` on success, `false` on failure. Returns `None` on API errors.
196    pub async fn auth_code(
197        &mut self,
198        exchange_token: Option<String>,
199        authorization_code: Option<String>,
200    ) -> bool {
201        self.try_auth_code(exchange_token, authorization_code)
202            .await
203            .unwrap_or(false)
204    }
205
206    /// Invalidate the current session and log out.
207    pub async fn logout(&mut self) -> bool {
208        self.egs.invalidate_sesion().await
209    }
210
211    /// Like [`login`](Self::login), but returns a `Result` instead of swallowing errors.
212    pub async fn try_login(&mut self) -> Result<bool, EpicAPIError> {
213        if let Some(exp) = self.egs.user_data.expires_at {
214            let now = chrono::offset::Utc::now();
215            let td = exp - now;
216            if td.num_seconds() > 600 {
217                info!("Trying to re-use existing login session... ");
218                let resumed = self.egs.resume_session().await.map_err(|e| {
219                    warn!("{}", e);
220                    e
221                })?;
222                if resumed {
223                    info!("Logged in");
224                    return Ok(true);
225                }
226                return Ok(false);
227            }
228        }
229        info!("Logging in...");
230        if let Some(exp) = self.egs.user_data.refresh_expires_at {
231            let now = chrono::offset::Utc::now();
232            let td = exp - now;
233            if td.num_seconds() > 600 {
234                let started = self.egs.start_session(None, None).await.map_err(|e| {
235                    error!("{}", e);
236                    e
237                })?;
238                if started {
239                    info!("Logged in");
240                    return Ok(true);
241                }
242                return Ok(false);
243            }
244        }
245        Ok(false)
246    }
247
248    /// Resume session using the saved refresh token.
249    ///
250    /// Returns `true` on success, `false` if the refresh token has expired or is invalid.
251    /// Unlike [`try_login`](Self::try_login), this method falls through to
252    /// refresh-token login if session resume fails.
253    pub async fn login(&mut self) -> bool {
254        if let Some(exp) = self.egs.user_data.expires_at {
255            let now = chrono::offset::Utc::now();
256            let td = exp - now;
257            if td.num_seconds() > 600 {
258                info!("Trying to re-use existing login session... ");
259                match self.egs.resume_session().await {
260                    Ok(b) => {
261                        if b {
262                            info!("Logged in");
263                            return true;
264                        }
265                        return false;
266                    }
267                    Err(e) => {
268                        warn!("{}", e)
269                    }
270                };
271            }
272        }
273        info!("Logging in...");
274        if let Some(exp) = self.egs.user_data.refresh_expires_at {
275            let now = chrono::offset::Utc::now();
276            let td = exp - now;
277            if td.num_seconds() > 600 {
278                match self.egs.start_session(None, None).await {
279                    Ok(b) => {
280                        if b {
281                            info!("Logged in");
282                            return true;
283                        }
284                        return false;
285                    }
286                    Err(e) => {
287                        error!("{}", e)
288                    }
289                }
290            }
291        }
292        false
293    }
294
295    /// Like [`list_assets`](Self::list_assets), but returns a `Result` instead of swallowing errors.
296    pub async fn try_list_assets(
297        &mut self,
298        platform: Option<String>,
299        label: Option<String>,
300    ) -> Result<Vec<EpicAsset>, EpicAPIError> {
301        self.egs.assets(platform, label).await
302    }
303
304    /// List all owned assets.
305    ///
306    /// Defaults to platform="Windows" and label="Live" if not specified.
307    /// Returns empty `Vec` on API errors.
308    pub async fn list_assets(
309        &mut self,
310        platform: Option<String>,
311        label: Option<String>,
312    ) -> Vec<EpicAsset> {
313        self.try_list_assets(platform, label)
314            .await
315            .unwrap_or_else(|_| Vec::new())
316    }
317
318    /// Like [`asset_manifest`](Self::asset_manifest), but returns a `Result` instead of swallowing errors.
319    pub async fn try_asset_manifest(
320        &mut self,
321        platform: Option<String>,
322        label: Option<String>,
323        namespace: Option<String>,
324        item_id: Option<String>,
325        app: Option<String>,
326    ) -> Result<AssetManifest, EpicAPIError> {
327        self.egs
328            .asset_manifest(platform, label, namespace, item_id, app)
329            .await
330    }
331
332    /// Fetch asset manifest with CDN download URLs.
333    ///
334    /// Defaults to platform="Windows" and label="Live" if not specified.
335    /// Returns `None` on API errors.
336    pub async fn asset_manifest(
337        &mut self,
338        platform: Option<String>,
339        label: Option<String>,
340        namespace: Option<String>,
341        item_id: Option<String>,
342        app: Option<String>,
343    ) -> Option<AssetManifest> {
344        self.try_asset_manifest(platform, label, namespace, item_id, app)
345            .await
346            .ok()
347    }
348
349    /// Fetch Fab asset manifest with signed distribution points.
350    ///
351    /// Returns `Result` to expose timeout errors (403 → `EpicAPIError::FabTimeout`).
352    pub async fn fab_asset_manifest(
353        &self,
354        artifact_id: &str,
355        namespace: &str,
356        asset_id: &str,
357        platform: Option<&str>,
358    ) -> Result<Vec<DownloadInfo>, EpicAPIError> {
359        match self
360            .egs
361            .fab_asset_manifest(artifact_id, namespace, asset_id, platform)
362            .await
363        {
364            Ok(a) => Ok(a),
365            Err(e) => Err(e),
366        }
367    }
368
369    /// Like [`asset_info`](Self::asset_info), but returns a `Result` instead of swallowing errors.
370    pub async fn try_asset_info(
371        &mut self,
372        asset: &EpicAsset,
373    ) -> Result<Option<AssetInfo>, EpicAPIError> {
374        let mut info = self.egs.asset_info(asset).await?;
375        Ok(info.remove(asset.catalog_item_id.as_str()))
376    }
377
378    /// Fetch catalog metadata for an asset (includes DLC tree).
379    ///
380    /// Returns `None` on API errors.
381    pub async fn asset_info(&mut self, asset: &EpicAsset) -> Option<AssetInfo> {
382        self.try_asset_info(asset).await.ok().flatten()
383    }
384
385    /// Like [`account_details`](Self::account_details), but returns a `Result` instead of swallowing errors.
386    pub async fn try_account_details(&mut self) -> Result<AccountData, EpicAPIError> {
387        self.egs.account_details().await
388    }
389
390    /// Fetch account details (email, display name, country, 2FA status).
391    ///
392    /// Returns `None` on API errors.
393    pub async fn account_details(&mut self) -> Option<AccountData> {
394        self.try_account_details().await.ok()
395    }
396
397    /// Like [`account_ids_details`](Self::account_ids_details), but returns a `Result` instead of swallowing errors.
398    pub async fn try_account_ids_details(
399        &mut self,
400        ids: Vec<String>,
401    ) -> Result<Vec<AccountInfo>, EpicAPIError> {
402        self.egs.account_ids_details(ids).await
403    }
404
405    /// Bulk lookup of account IDs to display names.
406    ///
407    /// Returns `None` on API errors.
408    pub async fn account_ids_details(&mut self, ids: Vec<String>) -> Option<Vec<AccountInfo>> {
409        self.try_account_ids_details(ids).await.ok()
410    }
411
412    /// Like [`account_friends`](Self::account_friends), but returns a `Result` instead of swallowing errors.
413    pub async fn try_account_friends(
414        &mut self,
415        include_pending: bool,
416    ) -> Result<Vec<Friend>, EpicAPIError> {
417        self.egs.account_friends(include_pending).await
418    }
419
420    /// Fetch friends list (including pending requests if `include_pending` is true).
421    ///
422    /// Returns `None` on API errors.
423    pub async fn account_friends(&mut self, include_pending: bool) -> Option<Vec<Friend>> {
424        self.try_account_friends(include_pending).await.ok()
425    }
426
427    /// Like [`game_token`](Self::game_token), but returns a `Result` instead of swallowing errors.
428    pub async fn try_game_token(&mut self) -> Result<GameToken, EpicAPIError> {
429        self.egs.game_token().await
430    }
431
432    /// Fetch a short-lived exchange code for game launches.
433    ///
434    /// Returns `None` on API errors.
435    pub async fn game_token(&mut self) -> Option<GameToken> {
436        self.try_game_token().await.ok()
437    }
438
439    /// Like [`ownership_token`](Self::ownership_token), but returns a `Result` instead of swallowing errors.
440    pub async fn try_ownership_token(&mut self, asset: &EpicAsset) -> Result<String, EpicAPIError> {
441        self.egs.ownership_token(asset).await.map(|a| a.token)
442    }
443
444    /// Fetch a JWT proving ownership of an asset.
445    ///
446    /// Returns `None` on API errors.
447    pub async fn ownership_token(&mut self, asset: &EpicAsset) -> Option<String> {
448        self.try_ownership_token(asset).await.ok()
449    }
450
451    /// Like [`user_entitlements`](Self::user_entitlements), but returns a `Result` instead of swallowing errors.
452    pub async fn try_user_entitlements(&mut self) -> Result<Vec<Entitlement>, EpicAPIError> {
453        self.egs.user_entitlements().await
454    }
455
456    /// Fetch all user entitlements (games, DLC, subscriptions).
457    ///
458    /// Returns empty `Vec` on API errors.
459    pub async fn user_entitlements(&mut self) -> Vec<Entitlement> {
460        self.try_user_entitlements().await.unwrap_or_else(|_| Vec::new())
461    }
462
463    /// Like [`library_items`](Self::library_items), but returns a `Result` instead of swallowing errors.
464    pub async fn try_library_items(&mut self, include_metadata: bool) -> Result<Library, EpicAPIError> {
465        self.egs.library_items(include_metadata).await
466    }
467
468    /// Fetch the user library with optional metadata.
469    ///
470    /// Paginates internally and returns all records at once. Returns `None` on API errors.
471    pub async fn library_items(&mut self, include_metadata: bool) -> Option<Library> {
472        self.try_library_items(include_metadata).await.ok()
473    }
474
475    /// Like [`fab_library_items`](Self::fab_library_items), but returns a `Result` instead of swallowing errors.
476    pub async fn try_fab_library_items(
477        &mut self,
478        account_id: String,
479    ) -> Result<api::types::fab_library::FabLibrary, EpicAPIError> {
480        self.egs.fab_library_items(account_id).await
481    }
482
483    /// Fetch the user Fab library.
484    ///
485    /// Paginates internally and returns all records at once. Returns `None` on API errors.
486    pub async fn fab_library_items(
487        &mut self,
488        account_id: String,
489    ) -> Option<api::types::fab_library::FabLibrary> {
490        self.try_fab_library_items(account_id).await.ok()
491    }
492
493    /// Parse download manifests from all CDN mirrors.
494    ///
495    /// Fetches from all mirrors, parses binary/JSON format, and populates custom fields
496    /// (BaseUrl, CatalogItemId, etc.). Returns empty `Vec` on API errors.
497    pub async fn asset_download_manifests(&self, manifest: AssetManifest) -> Vec<DownloadManifest> {
498        self.egs.asset_download_manifests(manifest).await
499    }
500
501    /// Parse a Fab download manifest from a specific distribution point.
502    ///
503    /// Checks signature expiration before fetching. Returns `Result` to expose timeout errors.
504    pub async fn fab_download_manifest(
505        &self,
506        download_info: DownloadInfo,
507        distribution_point_url: &str,
508    ) -> Result<DownloadManifest, EpicAPIError> {
509        self.egs
510            .fab_download_manifest(download_info, distribution_point_url)
511            .await
512    }
513
514    /// Like [`auth_client_credentials`](Self::auth_client_credentials), but returns a `Result` instead of swallowing errors.
515    pub async fn try_auth_client_credentials(&mut self) -> Result<bool, EpicAPIError> {
516        self.egs.start_client_credentials_session().await
517    }
518
519    /// Authenticate with client credentials (app-level, no user context).
520    ///
521    /// Uses the launcher's public client ID/secret to obtain an access token
522    /// without any user interaction. The resulting session has limited
523    /// permissions — it can query public endpoints (catalog, service status,
524    /// currencies) but cannot access user-specific data (library, entitlements).
525    ///
526    /// Returns `true` on success, `false` on failure.
527    pub async fn auth_client_credentials(&mut self) -> bool {
528        self.try_auth_client_credentials().await.unwrap_or(false)
529    }
530
531    /// Like [`external_auths`](Self::external_auths), but returns a `Result` instead of swallowing errors.
532    pub async fn try_external_auths(&self, account_id: &str) -> Result<Vec<ExternalAuth>, EpicAPIError> {
533        self.egs.external_auths(account_id).await
534    }
535
536    /// Fetch external auth connections linked to an account.
537    ///
538    /// Returns linked platform accounts (Steam, PSN, Xbox, Nintendo, etc.)
539    /// with external display names and account IDs. Requires a valid user session.
540    ///
541    /// Returns `None` on API errors.
542    pub async fn external_auths(&self, account_id: &str) -> Option<Vec<ExternalAuth>> {
543        self.try_external_auths(account_id).await.ok()
544    }
545
546    /// Like [`sso_domains`](Self::sso_domains), but returns a `Result` instead of swallowing errors.
547    pub async fn try_sso_domains(&self) -> Result<Vec<String>, EpicAPIError> {
548        self.egs.sso_domains().await
549    }
550
551    /// Fetch the list of SSO (Single Sign-On) domains.
552    ///
553    /// Returns domain strings that support Epic's SSO flow. Used by the
554    /// launcher to determine which domains can share authentication cookies.
555    ///
556    /// Returns `None` on API errors.
557    pub async fn sso_domains(&self) -> Option<Vec<String>> {
558        self.try_sso_domains().await.ok()
559    }
560
561    /// Like [`catalog_items`](Self::catalog_items), but returns a `Result` instead of swallowing errors.
562    pub async fn try_catalog_items(
563        &self,
564        namespace: &str,
565        start: i64,
566        count: i64,
567    ) -> Result<CatalogItemPage, EpicAPIError> {
568        self.egs.catalog_items(namespace, start, count).await
569    }
570
571    /// Fetch paginated catalog items for a namespace.
572    ///
573    /// Queries the Epic catalog service for items within a given namespace
574    /// (e.g., a game's namespace). Results are paginated — use `start` and
575    /// `count` to page through. Each [`CatalogItemPage`] includes a `paging`
576    /// field with the total count.
577    ///
578    /// Returns `None` on API errors.
579    pub async fn catalog_items(
580        &self,
581        namespace: &str,
582        start: i64,
583        count: i64,
584    ) -> Option<CatalogItemPage> {
585        self.try_catalog_items(namespace, start, count).await.ok()
586    }
587
588    /// Like [`catalog_offers`](Self::catalog_offers), but returns a `Result` instead of swallowing errors.
589    pub async fn try_catalog_offers(
590        &self,
591        namespace: &str,
592        start: i64,
593        count: i64,
594    ) -> Result<CatalogOfferPage, EpicAPIError> {
595        self.egs.catalog_offers(namespace, start, count).await
596    }
597
598    /// Fetch paginated catalog offers for a namespace.
599    ///
600    /// Queries the Epic catalog service for offers (purchasable items) within
601    /// a namespace. Offers include pricing metadata, seller info, and linked
602    /// catalog items. Use `start` and `count` to paginate.
603    ///
604    /// Returns `None` on API errors.
605    pub async fn catalog_offers(
606        &self,
607        namespace: &str,
608        start: i64,
609        count: i64,
610    ) -> Option<CatalogOfferPage> {
611        self.try_catalog_offers(namespace, start, count).await.ok()
612    }
613
614    /// Like [`bulk_catalog_items`](Self::bulk_catalog_items), but returns a `Result` instead of swallowing errors.
615    pub async fn try_bulk_catalog_items(
616        &self,
617        items: &[(&str, &str)],
618    ) -> Result<std::collections::HashMap<String, std::collections::HashMap<String, AssetInfo>>, EpicAPIError> {
619        self.egs.bulk_catalog_items(items).await
620    }
621
622    /// Bulk fetch catalog items across multiple namespaces.
623    ///
624    /// Accepts a slice of `(namespace, item_id)` pairs and returns them grouped
625    /// by namespace → item_id → [`AssetInfo`]. Useful for resolving catalog
626    /// metadata for items from different games in a single request.
627    ///
628    /// Returns `None` on API errors.
629    pub async fn bulk_catalog_items(
630        &self,
631        items: &[(&str, &str)],
632    ) -> Option<std::collections::HashMap<String, std::collections::HashMap<String, AssetInfo>>> {
633        self.try_bulk_catalog_items(items).await.ok()
634    }
635
636    /// Like [`currencies`](Self::currencies), but returns a `Result` instead of swallowing errors.
637    pub async fn try_currencies(&self, start: i64, count: i64) -> Result<CurrencyPage, EpicAPIError> {
638        self.egs.currencies(start, count).await
639    }
640
641    /// Fetch available currencies from the Epic catalog.
642    ///
643    /// Returns paginated currency definitions including code, symbol, and
644    /// decimal precision. Use `start` and `count` to paginate.
645    ///
646    /// Returns `None` on API errors.
647    pub async fn currencies(&self, start: i64, count: i64) -> Option<CurrencyPage> {
648        self.try_currencies(start, count).await.ok()
649    }
650
651    /// Like [`library_state_token_status`](Self::library_state_token_status), but returns a `Result` instead of swallowing errors.
652    pub async fn try_library_state_token_status(
653        &self,
654        token_id: &str,
655    ) -> Result<bool, EpicAPIError> {
656        self.egs.library_state_token_status(token_id).await
657    }
658
659    /// Check the validity of a library state token.
660    ///
661    /// Returns `Some(true)` if the token is still valid, `Some(false)` if
662    /// expired or invalid, or `None` on API errors. Library state tokens are
663    /// used to detect changes to the user's library since the last sync.
664    ///
665    /// Returns `None` on API errors.
666    pub async fn library_state_token_status(&self, token_id: &str) -> Option<bool> {
667        self.try_library_state_token_status(token_id).await.ok()
668    }
669
670    /// Like [`service_status`](Self::service_status), but returns a `Result` instead of swallowing errors.
671    pub async fn try_service_status(
672        &self,
673        service_id: &str,
674    ) -> Result<Vec<ServiceStatus>, EpicAPIError> {
675        self.egs.service_status(service_id).await
676    }
677
678    /// Fetch service status from Epic's lightswitch API.
679    ///
680    /// Returns the operational status of an Epic online service (e.g., a game's
681    /// backend). The response includes whether the service is UP/DOWN, any
682    /// maintenance message, and whether the current user is banned.
683    ///
684    /// Returns `None` on API errors.
685    pub async fn service_status(&self, service_id: &str) -> Option<Vec<ServiceStatus>> {
686        self.try_service_status(service_id).await.ok()
687    }
688
689    /// Like [`offer_prices`](Self::offer_prices), but returns a `Result` instead of swallowing errors.
690    pub async fn try_offer_prices(
691        &self,
692        namespace: &str,
693        offer_ids: &[String],
694        country: &str,
695    ) -> Result<PriceResponse, EpicAPIError> {
696        self.egs.offer_prices(namespace, offer_ids, country).await
697    }
698
699    /// Fetch offer prices from Epic's price engine.
700    ///
701    /// Queries current pricing for one or more offers within a namespace,
702    /// localized to a specific country. The response includes original price,
703    /// discount price, and pre-formatted display strings.
704    ///
705    /// Returns `None` on API errors.
706    pub async fn offer_prices(
707        &self,
708        namespace: &str,
709        offer_ids: &[String],
710        country: &str,
711    ) -> Option<PriceResponse> {
712        self.try_offer_prices(namespace, offer_ids, country).await.ok()
713    }
714
715    /// Like [`quick_purchase`](Self::quick_purchase), but returns a `Result` instead of swallowing errors.
716    pub async fn try_quick_purchase(
717        &self,
718        namespace: &str,
719        offer_id: &str,
720    ) -> Result<QuickPurchaseResponse, EpicAPIError> {
721        self.egs.quick_purchase(namespace, offer_id).await
722    }
723
724    /// Execute a quick purchase (typically for free game claims).
725    ///
726    /// Initiates a purchase order for a free offer. The response contains the
727    /// order ID and its processing status. For paid offers, use the full
728    /// checkout flow in the Epic Games launcher instead.
729    ///
730    /// Returns `None` on API errors.
731    pub async fn quick_purchase(
732        &self,
733        namespace: &str,
734        offer_id: &str,
735    ) -> Option<QuickPurchaseResponse> {
736        self.try_quick_purchase(namespace, offer_id).await.ok()
737    }
738
739    /// Like [`billing_account`](Self::billing_account), but returns a `Result` instead of swallowing errors.
740    pub async fn try_billing_account(&self) -> Result<BillingAccount, EpicAPIError> {
741        self.egs.billing_account().await
742    }
743
744    /// Fetch the default billing account for payment processing.
745    ///
746    /// Returns the account's billing country, which is used to determine
747    /// regional pricing and payment availability.
748    ///
749    /// Returns `None` on API errors.
750    pub async fn billing_account(&self) -> Option<BillingAccount> {
751        self.try_billing_account().await.ok()
752    }
753
754    /// Update the user's presence status.
755    ///
756    /// Sends a PATCH request to update the user's online presence (e.g.,
757    /// "online", "away") and optionally set an activity with custom properties.
758    /// The `session_id` is the OAuth session token from login. Returns `Ok(())`
759    /// on success (204 No Content) or an [`EpicAPIError`] on failure.
760    pub async fn update_presence(
761        &self,
762        session_id: &str,
763        body: &PresenceUpdate,
764    ) -> Result<(), EpicAPIError> {
765        self.egs.update_presence(session_id, body).await
766    }
767
768    /// Like [`fab_file_download_info`](Self::fab_file_download_info), but returns a `Result` instead of swallowing errors.
769    pub async fn try_fab_file_download_info(
770        &self,
771        listing_id: &str,
772        format_id: &str,
773        file_id: &str,
774    ) -> Result<DownloadInfo, EpicAPIError> {
775        self.egs
776            .fab_file_download_info(listing_id, format_id, file_id)
777            .await
778    }
779
780    /// Fetch download info for a specific file within a Fab listing.
781    ///
782    /// Returns signed [`DownloadInfo`] for a single file identified by
783    /// `listing_id`, `format_id`, and `file_id`. Use this for targeted
784    /// downloads of individual files from a Fab asset rather than fetching
785    /// the entire asset manifest.
786    ///
787    /// Returns `None` on API errors.
788    pub async fn fab_file_download_info(
789        &self,
790        listing_id: &str,
791        format_id: &str,
792        file_id: &str,
793    ) -> Option<DownloadInfo> {
794        self.try_fab_file_download_info(listing_id, format_id, file_id)
795            .await
796            .ok()
797    }
798
799    // ── Cloud Saves ──
800
801    /// List cloud save files for the logged-in user.
802    ///
803    /// If `app_name` is provided, lists saves for that specific game.
804    /// If `manifests` is true (only relevant when `app_name` is set), lists manifest files.
805    pub async fn cloud_save_list(
806        &self,
807        app_name: Option<&str>,
808        manifests: bool,
809    ) -> Result<CloudSaveResponse, EpicAPIError> {
810        self.egs.cloud_save_list(app_name, manifests).await
811    }
812
813    /// Query cloud save files by specific filenames.
814    ///
815    /// Returns metadata including read/write links for the specified files.
816    pub async fn cloud_save_query(
817        &self,
818        app_name: &str,
819        filenames: &[String],
820    ) -> Result<CloudSaveResponse, EpicAPIError> {
821        self.egs.cloud_save_query(app_name, filenames).await
822    }
823
824    /// Delete a cloud save file by its storage path.
825    pub async fn cloud_save_delete(&self, path: &str) -> Result<(), EpicAPIError> {
826        self.egs.cloud_save_delete(path).await
827    }
828
829    // ── Artifact Service & Manifests ──
830
831    /// Fetch an artifact service ticket for manifest retrieval via EOS Helper.
832    ///
833    /// The `sandbox_id` is typically the game's namespace and `artifact_id`
834    /// is the app name. Returns a signed ticket for use with
835    /// [`game_manifest_by_ticket`](Self::game_manifest_by_ticket).
836    pub async fn artifact_service_ticket(
837        &self,
838        sandbox_id: &str,
839        artifact_id: &str,
840        label: Option<&str>,
841        platform: Option<&str>,
842    ) -> Result<ArtifactServiceTicket, EpicAPIError> {
843        self.egs
844            .artifact_service_ticket(sandbox_id, artifact_id, label, platform)
845            .await
846    }
847
848    /// Fetch a game manifest using a signed artifact service ticket.
849    ///
850    /// Alternative to [`asset_manifest`](Self::asset_manifest) using ticket-based
851    /// auth from the EOS Helper service.
852    pub async fn game_manifest_by_ticket(
853        &self,
854        artifact_id: &str,
855        signed_ticket: &str,
856        label: Option<&str>,
857        platform: Option<&str>,
858    ) -> Result<AssetManifest, EpicAPIError> {
859        self.egs
860            .game_manifest_by_ticket(artifact_id, signed_ticket, label, platform)
861            .await
862    }
863
864    /// Fetch launcher manifests for self-update checks.
865    pub async fn launcher_manifests(
866        &self,
867        platform: Option<&str>,
868        label: Option<&str>,
869    ) -> Result<AssetManifest, EpicAPIError> {
870        self.egs.launcher_manifests(platform, label).await
871    }
872
873    /// Try to fetch a delta manifest for optimized patching between builds.
874    ///
875    /// Returns `None` if no delta is available or the builds are identical.
876    pub async fn delta_manifest(
877        &self,
878        base_url: &str,
879        old_build_id: &str,
880        new_build_id: &str,
881    ) -> Option<Vec<u8>> {
882        self.egs
883            .delta_manifest(base_url, old_build_id, new_build_id)
884            .await
885    }
886
887    // ── SID Auth ──
888
889    /// Authenticate via session ID (SID) from the Epic web login flow.
890    ///
891    /// Performs the multi-step web exchange: set-sid → CSRF → exchange code,
892    /// then starts a session with the resulting code. Returns `true` on success.
893    pub async fn auth_sid(&mut self, sid: &str) -> Result<bool, EpicAPIError> {
894        self.egs.auth_sid(sid).await
895    }
896
897    // ── Uplay / Ubisoft Store ──
898
899    /// Fetch Uplay codes linked to the user's Epic account.
900    pub async fn store_get_uplay_codes(
901        &self,
902    ) -> Result<UplayGraphQLResponse<UplayCodesResult>, EpicAPIError> {
903        self.egs.store_get_uplay_codes().await
904    }
905
906    /// Claim a Uplay code for a specific game.
907    pub async fn store_claim_uplay_code(
908        &self,
909        uplay_account_id: &str,
910        game_id: &str,
911    ) -> Result<UplayGraphQLResponse<UplayClaimResult>, EpicAPIError> {
912        self.egs
913            .store_claim_uplay_code(uplay_account_id, game_id)
914            .await
915    }
916
917    /// Redeem all pending Uplay codes for the user's account.
918    pub async fn store_redeem_uplay_codes(
919        &self,
920        uplay_account_id: &str,
921    ) -> Result<UplayGraphQLResponse<UplayRedeemResult>, EpicAPIError> {
922        self.egs.store_redeem_uplay_codes(uplay_account_id).await
923    }
924}
925
926#[cfg(test)]
927mod facade_tests {
928    use super::*;
929    use crate::api::types::account::UserData;
930    use chrono::{Duration, Utc};
931
932    #[test]
933    fn new_creates_instance() {
934        let egs = EpicGames::new();
935        assert!(!egs.is_logged_in());
936    }
937
938    #[test]
939    fn default_same_as_new() {
940        let egs = EpicGames::default();
941        assert!(!egs.is_logged_in());
942    }
943
944    #[test]
945    fn user_details_default_empty() {
946        let egs = EpicGames::new();
947        assert!(egs.user_details().access_token.is_none());
948    }
949
950    #[test]
951    fn set_and_get_user_details() {
952        let mut egs = EpicGames::new();
953        let mut ud = UserData::new();
954        ud.display_name = Some("TestUser".to_string());
955        egs.set_user_details(ud);
956        assert_eq!(egs.user_details().display_name, Some("TestUser".to_string()));
957    }
958
959    #[test]
960    fn is_logged_in_expired() {
961        let mut egs = EpicGames::new();
962        let mut ud = UserData::new();
963        ud.expires_at = Some(Utc::now() - Duration::hours(1));
964        egs.set_user_details(ud);
965        assert!(!egs.is_logged_in());
966    }
967
968    #[test]
969    fn is_logged_in_valid() {
970        let mut egs = EpicGames::new();
971        let mut ud = UserData::new();
972        ud.expires_at = Some(Utc::now() + Duration::hours(2));
973        egs.set_user_details(ud);
974        assert!(egs.is_logged_in());
975    }
976
977    #[test]
978    fn is_logged_in_within_600s_threshold() {
979        let mut egs = EpicGames::new();
980        let mut ud = UserData::new();
981        ud.expires_at = Some(Utc::now() + Duration::seconds(500));
982        egs.set_user_details(ud);
983        assert!(!egs.is_logged_in());
984    }
985}