epik/legendary/
mod.rs

1pub mod cli;
2pub mod workflow;
3
4use crate::api::{EpicClient, Game, GameManifest, ManifestParser};
5use crate::auth::AuthToken;
6use crate::{Error, Result};
7use egs_api::api::types::account::UserData;
8use egs_api::EpicGames;
9use serde_json;
10
11#[derive(Debug)]
12pub struct LegendaryClient {
13    client: EpicGames,
14    epic_client: EpicClient,
15}
16
17impl LegendaryClient {
18    pub fn new() -> Result<Self> {
19        Ok(Self {
20            client: EpicGames::new(),
21            epic_client: EpicClient::new()?,
22        })
23    }
24
25    pub async fn exchange_auth_code(&mut self, code: &str) -> Result<AuthToken> {
26        let success = self.client.auth_code(Some(code.to_string()), None).await;
27        if !success {
28            return Err(Error::Auth("Failed to exchange auth code".to_string()));
29        }
30        self.get_token()
31    }
32
33    pub async fn resume_session(&mut self, token: &AuthToken) -> bool {
34        let json = serde_json::json!({
35            "access_token": token.access_token,
36            "refresh_token": token.refresh_token,
37            "account_id": token.account_id,
38            "expires_at": token.expires_at,
39        });
40
41        if let Ok(user_data) = serde_json::from_value::<UserData>(json) {
42            self.client.set_user_details(user_data);
43            return self.client.login().await;
44        }
45        false
46    }
47
48    pub fn get_token(&self) -> Result<AuthToken> {
49        let user_data = self.client.user_details();
50        let val = serde_json::to_value(&user_data)
51            .map_err(|e| Error::Auth(format!("Failed to serialize user data: {}", e)))?;
52
53        Ok(AuthToken {
54            access_token: val["access_token"].as_str().unwrap_or_default().to_string(),
55            refresh_token: val["refresh_token"]
56                .as_str()
57                .unwrap_or_default()
58                .to_string(),
59            account_id: val["account_id"].as_str().unwrap_or_default().to_string(),
60            expires_at: chrono::Utc::now() + chrono::Duration::hours(1), // TODO: parse expires_at from JSON
61        })
62    }
63
64    pub async fn get_games(&mut self) -> Result<Vec<Game>> {
65        let assets = self.client.list_assets(None, None).await;
66        let token = self.get_token()?;
67
68        let mut games = Vec::new();
69        for asset in assets {
70            // Check if app_name is a UUID (32 hex chars)
71            let is_uuid =
72                asset.app_name.len() == 32 && asset.app_name.chars().all(|c| c.is_alphanumeric());
73
74            if is_uuid {
75                // Try to get real title from catalog synchronously
76                log::debug!(
77                    "Enriching UUID game: {} with catalog {}",
78                    asset.app_name,
79                    asset.catalog_item_id
80                );
81                let enriched = self
82                    .epic_client
83                    .fetch_catalog_item(&token, &asset.namespace, &asset.catalog_item_id)
84                    .await;
85
86                match enriched {
87                    Ok(mut game) => {
88                        game.app_name = asset.app_name.clone();
89                        game.app_version = asset.build_version;
90                        game.namespace = Some(asset.namespace);
91                        game.catalog_item_id = Some(asset.catalog_item_id);
92                        log::info!("Enriched {} -> {}", asset.app_name, game.app_title);
93                        games.push(game);
94                    }
95                    Err(e) => {
96                        log::debug!(
97                            "Catalog not found for {}: {}, using generic name",
98                            asset.app_name,
99                            e
100                        );
101                        // Use a generic but informative title
102                        let title = format!("Epic Game ({})", &asset.app_name[..6]);
103                        games.push(Game {
104                            app_name: asset.app_name.clone(),
105                            app_title: title,
106                            app_version: asset.build_version,
107                            install_path: None,
108                            image_url: None,
109                            namespace: Some(asset.namespace),
110                            catalog_item_id: Some(asset.catalog_item_id),
111                        });
112                    }
113                }
114            } else {
115                // Already has a readable name
116                games.push(Game {
117                    app_name: asset.app_name.clone(),
118                    app_title: asset.app_name.clone(),
119                    app_version: asset.build_version,
120                    install_path: None,
121                    image_url: None,
122                    namespace: Some(asset.namespace),
123                    catalog_item_id: Some(asset.catalog_item_id),
124                });
125            }
126        }
127        Ok(games)
128    }
129    /// Try to enrich a game with catalog details (images, proper title)
130    /// This is non-blocking and uses a fallback if the catalog item doesn't exist
131    pub async fn enrich_game_details(
132        &self,
133        token: &AuthToken,
134        app_name: &str,
135        namespace: &str,
136        catalog_item_id: &str,
137    ) -> Game {
138        match self
139            .epic_client
140            .fetch_catalog_item(token, namespace, catalog_item_id)
141            .await
142        {
143            Ok(mut game) => {
144                game.app_name = app_name.to_string();
145                game.app_title = self.humanize_app_name(&game.app_title);
146                game
147            }
148            Err(e) => {
149                log::debug!("Could not enrich game {} from catalog: {}", app_name, e);
150                Game {
151                    app_name: app_name.to_string(),
152                    app_title: self.humanize_app_name(app_name),
153                    app_version: "1.0.0".to_string(),
154                    install_path: None,
155                    image_url: None,
156                    namespace: Some(namespace.to_string()),
157                    catalog_item_id: Some(catalog_item_id.to_string()),
158                }
159            }
160        }
161    }
162
163    /// Convert app names like "7580986b33344aeb8fd733caa7457a72" to a loading placeholder
164    fn humanize_app_name(&self, app_name: &str) -> String {
165        if app_name.len() == 32 && app_name.chars().all(|c| c.is_alphanumeric()) {
166            // Likely a UUID - show loading placeholder, will be enriched
167            "Loading...".to_string()
168        } else {
169            // Already human-readable
170            app_name.to_string()
171        }
172    }
173
174    // --- Installation Support with Real Manifests ---
175
176    pub async fn download_manifest(
177        &mut self,
178        _token: &AuthToken,
179        app_name: &str,
180    ) -> Result<GameManifest> {
181        log::info!("Fetching manifest for: {}", app_name);
182
183        // Get the asset for this game
184        let assets = self.client.list_assets(None, None).await;
185        let asset = assets
186            .iter()
187            .find(|a| a.app_name == app_name)
188            .ok_or_else(|| Error::GameNotFound(app_name.to_string()))?;
189
190        log::debug!("Found asset: {} ({})", asset.app_name, asset.asset_id);
191
192        // For now, we use a fallback manifest structure
193        // TODO: Implement proper manifest download via CDN URLs
194        // The egs_api asset_manifest method structure needs more investigation
195        // to properly extract the binary manifest data
196
197        log::warn!("Using fallback manifest - full CDN integration pending");
198        Self::fallback_manifest(app_name, &asset.build_version)
199    }
200
201    fn fallback_manifest(app_name: &str, version: &str) -> Result<GameManifest> {
202        Ok(GameManifest {
203            manifest_file_version: "21".to_string(),
204            is_file_data: true,
205            app_name: app_name.to_string(),
206            app_version: version.to_string(),
207            launch_exe: if cfg!(windows) {
208                format!("{}.exe", app_name)
209            } else {
210                "start.sh".to_string()
211            },
212            launch_command: String::new(),
213            build_size: 0,
214            file_list: Vec::new(),
215            chunk_hash_list: std::collections::HashMap::new(),
216            chunk_sha_list: std::collections::HashMap::new(),
217            data_group_list: std::collections::HashMap::new(),
218        })
219    }
220
221    #[allow(dead_code)]
222    fn parse_manifest(manifest_data: Vec<u8>, app_name: &str) -> Result<GameManifest> {
223        log::debug!("Parsing manifest data: {} bytes", manifest_data.len());
224
225        // Use the manifest parser to parse Epic's binary format
226        match ManifestParser::parse(&manifest_data) {
227            Ok(manifest) => {
228                log::info!(
229                    "Successfully parsed manifest for {}: {} files",
230                    app_name,
231                    manifest.file_list.len()
232                );
233                Ok(manifest)
234            }
235            Err(e) => {
236                log::error!("Failed to parse manifest for {}: {}", app_name, e);
237                // Fallback to minimal structure
238                Ok(GameManifest {
239                    manifest_file_version: "21".to_string(),
240                    is_file_data: true,
241                    app_name: app_name.to_string(),
242                    app_version: "1.0.0".to_string(),
243                    launch_exe: if cfg!(windows) {
244                        format!("{}.exe", app_name)
245                    } else {
246                        "start.sh".to_string()
247                    },
248                    launch_command: String::new(),
249                    build_size: manifest_data.len() as u64,
250                    file_list: Vec::new(),
251                    chunk_hash_list: std::collections::HashMap::new(),
252                    chunk_sha_list: std::collections::HashMap::new(),
253                    data_group_list: std::collections::HashMap::new(),
254                })
255            }
256        }
257    }
258
259    pub async fn download_chunk(&self, _chunk_guid: &str, _token: &AuthToken) -> Result<Vec<u8>> {
260        Ok(Vec::new())
261    }
262
263    pub async fn check_for_updates(
264        &self,
265        _token: &AuthToken,
266        _app_name: &str,
267        _current_version: &str,
268    ) -> Result<Option<String>> {
269        Ok(None)
270    }
271
272    pub async fn get_cloud_saves(
273        &self,
274        _token: &AuthToken,
275        _app_name: &str,
276    ) -> Result<Vec<crate::api::CloudSave>> {
277        Ok(Vec::new())
278    }
279
280    pub async fn download_cloud_save(&self, _token: &AuthToken, _save_id: &str) -> Result<Vec<u8>> {
281        Ok(Vec::new())
282    }
283
284    pub async fn upload_cloud_save(
285        &self,
286        _token: &AuthToken,
287        _app_name: &str,
288        _data: &[u8],
289    ) -> Result<()> {
290        Ok(())
291    }
292}