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}