1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14pub enum NetworkStatus {
15 Online,
16 Offline,
17 CheckingConnection,
18}
19
20pub struct OfflineManager {
22 is_online: Arc<RwLock<NetworkStatus>>,
23 cache_dir: PathBuf,
24}
25
26#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct OfflineSyncManifest {
55 pub last_sync: chrono::DateTime<chrono::Utc>,
56 pub synced_items: Vec<String>, pub pending_syncs: Vec<String>, }
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 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 async fn check_connection(&self) -> NetworkStatus {
81 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 pub async fn get_status(&self) -> NetworkStatus {
106 *self.is_online.read().await
107 }
108
109 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 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 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 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 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 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 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 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 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 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 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 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 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}