epik/offline/
mod.rs

1// Offline Mode Support for v1.2.0
2// Enables playing installed games offline with cached library data
3
4use crate::{Error, Result};
5use crate::config::Config;
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::PathBuf;
9use std::sync::Arc;
10use tokio::sync::RwLock;
11
12// Network connectivity status
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14pub enum NetworkStatus {
15    Online,
16    Offline,
17    CheckingConnection,
18}
19
20// Offline mode manager
21pub struct OfflineManager {
22    is_online: Arc<RwLock<NetworkStatus>>,
23    cache_dir: PathBuf,
24}
25
26// Cached game library data
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct CachedLibraryData {
29    pub games: Vec<CachedGameInfo>,
30    pub cached_at: chrono::DateTime<chrono::Utc>,
31    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
32}
33
34// Cached game info (lightweight)
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct CachedGameInfo {
37    pub app_name: String,
38    pub app_title: String,
39    pub namespace: String,
40    pub catalog_item_id: String,
41    pub key_images: Vec<CachedImageInfo>,
42    pub description: Option<String>,
43    pub version: Option<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct CachedImageInfo {
48    pub url: Option<String>,
49    pub image_type: Option<String>,
50}
51
52// Offline sync manifest
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct OfflineSyncManifest {
55    pub last_sync: chrono::DateTime<chrono::Utc>,
56    pub synced_items: Vec<String>, // app_names
57    pub pending_syncs: Vec<String>, // app_names waiting to sync when online
58}
59
60impl OfflineManager {
61    pub fn new(_config: &Config) -> Result<Self> {
62        let cache_dir = Config::data_dir()?.join("offline_cache");
63        fs::create_dir_all(&cache_dir)?;
64
65        Ok(Self {
66            is_online: Arc::new(RwLock::new(NetworkStatus::Online)),
67            cache_dir,
68        })
69    }
70
71    // Check internet connectivity
72    pub async fn check_connectivity(&self) -> Result<NetworkStatus> {
73        let status = self.check_connection().await;
74        let mut current = self.is_online.write().await;
75        *current = status;
76        Ok(status)
77    }
78
79    // Internal connectivity check (simple HTTP check)
80    async fn check_connection(&self) -> NetworkStatus {
81        // Try multiple common endpoints
82        let endpoints = vec![
83            "https://api.epicgames.com",
84            "https://www.google.com",
85            "https://www.cloudflare.com",
86        ];
87
88        for endpoint in endpoints {
89            if let Ok(response) = reqwest::Client::new()
90                .get(endpoint)
91                .timeout(std::time::Duration::from_secs(5))
92                .send()
93                .await
94            {
95                if response.status().is_success() || response.status().is_redirection() {
96                    return NetworkStatus::Online;
97                }
98            }
99        }
100
101        NetworkStatus::Offline
102    }
103
104    // Get current network status
105    pub async fn get_status(&self) -> NetworkStatus {
106        *self.is_online.read().await
107    }
108
109    // Cache library data for offline access
110    pub async fn cache_library_data(&self, games: Vec<CachedGameInfo>) -> Result<()> {
111        let cache_data = CachedLibraryData {
112            games,
113            cached_at: chrono::Utc::now(),
114            expires_at: Some(chrono::Utc::now() + chrono::Duration::days(7)),
115        };
116
117        let cache_file = self.cache_dir.join("library_cache.json");
118        let json = serde_json::to_string_pretty(&cache_data)?;
119        fs::write(&cache_file, json)?;
120
121        Ok(())
122    }
123
124    // Load cached library data
125    pub async fn load_cached_library(&self) -> Result<Option<CachedLibraryData>> {
126        let cache_file = self.cache_dir.join("library_cache.json");
127
128        if !cache_file.exists() {
129            return Ok(None);
130        }
131
132        let json = fs::read_to_string(&cache_file)?;
133        let cache_data: CachedLibraryData = serde_json::from_str(&json)?;
134
135        // Check if cache is still valid
136        if let Some(expires_at) = cache_data.expires_at {
137            if chrono::Utc::now() > expires_at {
138                return Ok(None);
139            }
140        }
141
142        Ok(Some(cache_data))
143    }
144
145    // Check if cache is stale
146    pub async fn is_cache_stale(&self) -> Result<bool> {
147        if let Some(cache) = self.load_cached_library().await? {
148            if let Some(expires_at) = cache.expires_at {
149                return Ok(chrono::Utc::now() > expires_at);
150            }
151        }
152        Ok(true)
153    }
154
155    // Cache game image
156    pub async fn cache_game_image(&self, app_name: &str, image_url: &str, image_type: &str) -> Result<PathBuf> {
157        let images_dir = self.cache_dir.join("images").join(app_name);
158        fs::create_dir_all(&images_dir)?;
159
160        let filename = format!("{}_{}.png", image_type, chrono::Utc::now().timestamp());
161        let filepath = images_dir.join(filename);
162
163        // Download and cache image
164        let client = reqwest::Client::new();
165        let response = client
166            .get(image_url)
167            .timeout(std::time::Duration::from_secs(10))
168            .send()
169            .await?;
170
171        if response.status().is_success() {
172            let bytes = response.bytes().await?;
173            fs::write(&filepath, bytes)?;
174            Ok(filepath)
175        } else {
176            Err(Error::Other(format!("Failed to download image: {}", image_url)))
177        }
178    }
179
180    // Get cached image for game
181    pub async fn get_cached_image(&self, app_name: &str, image_type: &str) -> Result<Option<PathBuf>> {
182        let images_dir = self.cache_dir.join("images").join(app_name);
183
184        if !images_dir.exists() {
185            return Ok(None);
186        }
187
188        for entry in fs::read_dir(&images_dir)? {
189            let entry = entry?;
190            let path = entry.path();
191            let filename = path.file_name().unwrap().to_string_lossy();
192
193            if filename.starts_with(image_type) {
194                return Ok(Some(path));
195            }
196        }
197
198        Ok(None)
199    }
200
201    // Register pending sync (for cloud saves, etc.)
202    pub async fn register_pending_sync(&self, app_name: &str) -> Result<()> {
203        let manifest_file = self.cache_dir.join("sync_manifest.json");
204
205        let mut manifest = if manifest_file.exists() {
206            let json = fs::read_to_string(&manifest_file)?;
207            serde_json::from_str(&json)?
208        } else {
209            OfflineSyncManifest {
210                last_sync: chrono::Utc::now(),
211                synced_items: Vec::new(),
212                pending_syncs: Vec::new(),
213            }
214        };
215
216        if !manifest.pending_syncs.contains(&app_name.to_string()) {
217            manifest.pending_syncs.push(app_name.to_string());
218        }
219
220        let json = serde_json::to_string_pretty(&manifest)?;
221        fs::write(&manifest_file, json)?;
222
223        Ok(())
224    }
225
226    // Get pending syncs
227    pub async fn get_pending_syncs(&self) -> Result<Vec<String>> {
228        let manifest_file = self.cache_dir.join("sync_manifest.json");
229
230        if !manifest_file.exists() {
231            return Ok(Vec::new());
232        }
233
234        let json = fs::read_to_string(&manifest_file)?;
235        let manifest: OfflineSyncManifest = serde_json::from_str(&json)?;
236
237        Ok(manifest.pending_syncs)
238    }
239
240    // Clear pending sync for app
241    pub async fn clear_pending_sync(&self, app_name: &str) -> Result<()> {
242        let manifest_file = self.cache_dir.join("sync_manifest.json");
243
244        if manifest_file.exists() {
245            let json = fs::read_to_string(&manifest_file)?;
246            let mut manifest: OfflineSyncManifest = serde_json::from_str(&json)?;
247
248            manifest.pending_syncs.retain(|a| a != app_name);
249            manifest.last_sync = chrono::Utc::now();
250            manifest.synced_items.push(app_name.to_string());
251
252            let json = serde_json::to_string_pretty(&manifest)?;
253            fs::write(&manifest_file, json)?;
254        }
255
256        Ok(())
257    }
258
259    // Monitor network status and trigger sync when back online
260    pub async fn monitor_connectivity(self: Arc<Self>, on_online: impl Fn() + Send + Sync + 'static) {
261        let on_online = Arc::new(on_online);
262        let mut last_status = NetworkStatus::Online;
263
264        loop {
265            let status = self.check_connectivity().await.unwrap_or(NetworkStatus::Offline);
266
267            // Trigger sync when coming back online
268            if last_status == NetworkStatus::Offline && status == NetworkStatus::Online {
269                log::info!("Network restored, triggering sync");
270                on_online();
271            }
272
273            last_status = status;
274            tokio::time::sleep(std::time::Duration::from_secs(30)).await;
275        }
276    }
277
278    // Clear offline cache
279    pub async fn clear_cache(&self) -> Result<()> {
280        fs::remove_dir_all(&self.cache_dir)?;
281        fs::create_dir_all(&self.cache_dir)?;
282        Ok(())
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn test_cache_data_expiry() {
292        let cache_data = CachedLibraryData {
293            games: vec![],
294            cached_at: chrono::Utc::now(),
295            expires_at: Some(chrono::Utc::now() + chrono::Duration::seconds(-1)),
296        };
297
298        let now = chrono::Utc::now();
299        assert!(cache_data.expires_at.unwrap() < now);
300    }
301
302    #[test]
303    fn test_sync_manifest_serialization() {
304        let manifest = OfflineSyncManifest {
305            last_sync: chrono::Utc::now(),
306            synced_items: vec!["game1".to_string()],
307            pending_syncs: vec!["game2".to_string()],
308        };
309
310        let json = serde_json::to_string(&manifest).unwrap();
311        let deserialized: OfflineSyncManifest = serde_json::from_str(&json).unwrap();
312
313        assert_eq!(deserialized.synced_items[0], "game1");
314        assert_eq!(deserialized.pending_syncs[0], "game2");
315    }
316}