| 1 | //! Steam library and game discovery |
| 2 | |
| 3 | use crate::config::WandaConfig; |
| 4 | use crate::error::{Result, WandaError}; |
| 5 | use crate::steam::vdf::parse_vdf_file; |
| 6 | use std::collections::HashMap; |
| 7 | use std::path::{Path, PathBuf}; |
| 8 | use tracing::{debug, info, warn}; |
| 9 | |
| 10 | /// A Steam installation with all its libraries |
| 11 | #[derive(Debug, Clone)] |
| 12 | pub struct SteamInstallation { |
| 13 | /// Root Steam installation path |
| 14 | pub root_path: PathBuf, |
| 15 | /// All discovered library folders |
| 16 | pub libraries: Vec<SteamLibrary>, |
| 17 | /// Whether this is a Flatpak installation |
| 18 | pub is_flatpak: bool, |
| 19 | } |
| 20 | |
| 21 | /// A Steam library folder containing games |
| 22 | #[derive(Debug, Clone)] |
| 23 | pub struct SteamLibrary { |
| 24 | /// Path to the library folder |
| 25 | pub path: PathBuf, |
| 26 | /// Optional label for this library |
| 27 | pub label: Option<String>, |
| 28 | /// Installed apps in this library (app_id -> SteamApp) |
| 29 | pub apps: HashMap<u32, SteamApp>, |
| 30 | } |
| 31 | |
| 32 | /// A Steam game/application |
| 33 | #[derive(Debug, Clone)] |
| 34 | pub struct SteamApp { |
| 35 | /// Steam App ID |
| 36 | pub app_id: u32, |
| 37 | /// Game name |
| 38 | pub name: String, |
| 39 | /// Installation directory name (relative to steamapps/common/) |
| 40 | pub install_dir: String, |
| 41 | /// Full path to the game installation |
| 42 | pub install_path: PathBuf, |
| 43 | /// Library this game belongs to |
| 44 | pub library_path: PathBuf, |
| 45 | /// Size on disk in bytes |
| 46 | pub size_on_disk: u64, |
| 47 | /// Path to the Proton compatdata directory (if using Proton) |
| 48 | pub compat_data_path: Option<PathBuf>, |
| 49 | } |
| 50 | |
| 51 | impl SteamInstallation { |
| 52 | /// Discover Steam installation on the system |
| 53 | pub fn discover(config: &WandaConfig) -> Result<Self> { |
| 54 | // Check for user-specified path first |
| 55 | if let Some(ref path) = config.steam.install_path { |
| 56 | if Self::is_valid_steam_path(path) { |
| 57 | info!("Using configured Steam path: {}", path.display()); |
| 58 | return Self::from_path(path.clone(), false); |
| 59 | } |
| 60 | warn!( |
| 61 | "Configured Steam path {} is invalid, falling back to auto-detection", |
| 62 | path.display() |
| 63 | ); |
| 64 | } |
| 65 | |
| 66 | // Try standard locations |
| 67 | let candidates = Self::get_candidate_paths(config.steam.scan_flatpak); |
| 68 | |
| 69 | for (path, is_flatpak) in candidates { |
| 70 | if Self::is_valid_steam_path(&path) { |
| 71 | info!("Found Steam installation at: {}", path.display()); |
| 72 | return Self::from_path(path, is_flatpak); |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | Err(WandaError::SteamNotFound) |
| 77 | } |
| 78 | |
| 79 | /// Get candidate Steam installation paths |
| 80 | fn get_candidate_paths(scan_flatpak: bool) -> Vec<(PathBuf, bool)> { |
| 81 | let mut candidates = Vec::new(); |
| 82 | |
| 83 | // Standard native Steam locations |
| 84 | if let Some(data_dir) = dirs::data_local_dir() { |
| 85 | candidates.push((data_dir.join("Steam"), false)); |
| 86 | } |
| 87 | if let Some(home) = dirs::home_dir() { |
| 88 | candidates.push((home.join(".steam/steam"), false)); |
| 89 | candidates.push((home.join(".steam/root"), false)); |
| 90 | } |
| 91 | |
| 92 | // Flatpak Steam |
| 93 | if scan_flatpak { |
| 94 | if let Some(home) = dirs::home_dir() { |
| 95 | candidates.push(( |
| 96 | home.join(".var/app/com.valvesoftware.Steam/.local/share/Steam"), |
| 97 | true, |
| 98 | )); |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | candidates |
| 103 | } |
| 104 | |
| 105 | /// Check if a path is a valid Steam installation |
| 106 | fn is_valid_steam_path(path: &Path) -> bool { |
| 107 | // Steam should have a config directory with libraryfolders.vdf |
| 108 | let config_vdf = path.join("config/libraryfolders.vdf"); |
| 109 | let steamapps_vdf = path.join("steamapps/libraryfolders.vdf"); |
| 110 | |
| 111 | config_vdf.exists() || steamapps_vdf.exists() |
| 112 | } |
| 113 | |
| 114 | /// Create SteamInstallation from a known valid path |
| 115 | fn from_path(root_path: PathBuf, is_flatpak: bool) -> Result<Self> { |
| 116 | let mut installation = Self { |
| 117 | root_path: root_path.clone(), |
| 118 | libraries: Vec::new(), |
| 119 | is_flatpak, |
| 120 | }; |
| 121 | |
| 122 | // Parse library folders |
| 123 | let library_folders_path = if root_path.join("config/libraryfolders.vdf").exists() { |
| 124 | root_path.join("config/libraryfolders.vdf") |
| 125 | } else { |
| 126 | root_path.join("steamapps/libraryfolders.vdf") |
| 127 | }; |
| 128 | |
| 129 | installation.parse_library_folders(&library_folders_path)?; |
| 130 | |
| 131 | Ok(installation) |
| 132 | } |
| 133 | |
| 134 | /// Parse libraryfolders.vdf to discover all library locations |
| 135 | fn parse_library_folders(&mut self, vdf_path: &Path) -> Result<()> { |
| 136 | let vdf = parse_vdf_file(vdf_path)?; |
| 137 | |
| 138 | let folders = vdf |
| 139 | .get("libraryfolders") |
| 140 | .ok_or_else(|| WandaError::VdfParseError { |
| 141 | path: vdf_path.to_path_buf(), |
| 142 | reason: "Missing 'libraryfolders' key".to_string(), |
| 143 | })?; |
| 144 | |
| 145 | let folders_obj = folders.as_object().ok_or_else(|| WandaError::VdfParseError { |
| 146 | path: vdf_path.to_path_buf(), |
| 147 | reason: "'libraryfolders' is not an object".to_string(), |
| 148 | })?; |
| 149 | |
| 150 | for (key, value) in folders_obj { |
| 151 | // Library entries are numbered: "0", "1", "2", etc. |
| 152 | if key.parse::<u32>().is_err() { |
| 153 | continue; |
| 154 | } |
| 155 | |
| 156 | if let Some(path_str) = value.get_str("path") { |
| 157 | let library_path = PathBuf::from(path_str); |
| 158 | let label = value.get_str("label").map(String::from); |
| 159 | |
| 160 | debug!("Found Steam library: {}", library_path.display()); |
| 161 | |
| 162 | let mut library = SteamLibrary { |
| 163 | path: library_path.clone(), |
| 164 | label, |
| 165 | apps: HashMap::new(), |
| 166 | }; |
| 167 | |
| 168 | // Scan for installed apps |
| 169 | library.scan_apps()?; |
| 170 | self.libraries.push(library); |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | info!("Discovered {} Steam libraries", self.libraries.len()); |
| 175 | Ok(()) |
| 176 | } |
| 177 | |
| 178 | /// Get all installed games across all libraries |
| 179 | pub fn get_all_games(&self) -> Vec<&SteamApp> { |
| 180 | self.libraries |
| 181 | .iter() |
| 182 | .flat_map(|lib| lib.apps.values()) |
| 183 | .collect() |
| 184 | } |
| 185 | |
| 186 | /// Find a game by app ID |
| 187 | pub fn find_game(&self, app_id: u32) -> Option<&SteamApp> { |
| 188 | for library in &self.libraries { |
| 189 | if let Some(app) = library.apps.get(&app_id) { |
| 190 | return Some(app); |
| 191 | } |
| 192 | } |
| 193 | None |
| 194 | } |
| 195 | |
| 196 | /// Find a game by name (case-insensitive partial match) |
| 197 | pub fn find_game_by_name(&self, name: &str) -> Vec<&SteamApp> { |
| 198 | let name_lower = name.to_lowercase(); |
| 199 | self.get_all_games() |
| 200 | .into_iter() |
| 201 | .filter(|app| app.name.to_lowercase().contains(&name_lower)) |
| 202 | .collect() |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | impl SteamLibrary { |
| 207 | /// Scan for installed apps in this library |
| 208 | fn scan_apps(&mut self) -> Result<()> { |
| 209 | let steamapps_dir = self.path.join("steamapps"); |
| 210 | if !steamapps_dir.exists() { |
| 211 | return Ok(()); |
| 212 | } |
| 213 | |
| 214 | // Look for appmanifest_*.acf files |
| 215 | let entries = std::fs::read_dir(&steamapps_dir).map_err(|e| { |
| 216 | WandaError::SteamLibraryInaccessible { |
| 217 | path: self.path.clone(), |
| 218 | reason: e.to_string(), |
| 219 | } |
| 220 | })?; |
| 221 | |
| 222 | for entry in entries.flatten() { |
| 223 | let path = entry.path(); |
| 224 | let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); |
| 225 | |
| 226 | if filename.starts_with("appmanifest_") && filename.ends_with(".acf") { |
| 227 | match self.parse_app_manifest(&path) { |
| 228 | Ok(app) => { |
| 229 | debug!("Found game: {} ({})", app.name, app.app_id); |
| 230 | self.apps.insert(app.app_id, app); |
| 231 | } |
| 232 | Err(e) => { |
| 233 | warn!("Failed to parse {}: {}", path.display(), e); |
| 234 | } |
| 235 | } |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | info!( |
| 240 | "Found {} games in library {}", |
| 241 | self.apps.len(), |
| 242 | self.path.display() |
| 243 | ); |
| 244 | Ok(()) |
| 245 | } |
| 246 | |
| 247 | /// Parse an appmanifest_*.acf file |
| 248 | fn parse_app_manifest(&self, acf_path: &Path) -> Result<SteamApp> { |
| 249 | let vdf = parse_vdf_file(acf_path)?; |
| 250 | |
| 251 | let app_state = vdf.get("AppState").ok_or_else(|| WandaError::VdfParseError { |
| 252 | path: acf_path.to_path_buf(), |
| 253 | reason: "Missing 'AppState' key".to_string(), |
| 254 | })?; |
| 255 | |
| 256 | let app_id: u32 = app_state |
| 257 | .get_str("appid") |
| 258 | .ok_or_else(|| WandaError::VdfParseError { |
| 259 | path: acf_path.to_path_buf(), |
| 260 | reason: "Missing 'appid'".to_string(), |
| 261 | })? |
| 262 | .parse() |
| 263 | .map_err(|_| WandaError::VdfParseError { |
| 264 | path: acf_path.to_path_buf(), |
| 265 | reason: "Invalid 'appid'".to_string(), |
| 266 | })?; |
| 267 | |
| 268 | let name = app_state |
| 269 | .get_str("name") |
| 270 | .unwrap_or("Unknown") |
| 271 | .to_string(); |
| 272 | |
| 273 | let install_dir = app_state |
| 274 | .get_str("installdir") |
| 275 | .unwrap_or("") |
| 276 | .to_string(); |
| 277 | |
| 278 | let size_on_disk: u64 = app_state |
| 279 | .get_str("SizeOnDisk") |
| 280 | .and_then(|s| s.parse().ok()) |
| 281 | .unwrap_or(0); |
| 282 | |
| 283 | let install_path = self.path.join("steamapps/common").join(&install_dir); |
| 284 | let compat_data_path = self.path.join("steamapps/compatdata").join(app_id.to_string()); |
| 285 | |
| 286 | Ok(SteamApp { |
| 287 | app_id, |
| 288 | name, |
| 289 | install_dir, |
| 290 | install_path, |
| 291 | library_path: self.path.clone(), |
| 292 | size_on_disk, |
| 293 | compat_data_path: if compat_data_path.exists() { |
| 294 | Some(compat_data_path) |
| 295 | } else { |
| 296 | None |
| 297 | }, |
| 298 | }) |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | impl SteamApp { |
| 303 | /// Check if this game uses Proton (has compatdata) |
| 304 | pub fn uses_proton(&self) -> bool { |
| 305 | self.compat_data_path.is_some() |
| 306 | } |
| 307 | |
| 308 | /// Get the Wine prefix path for this game (inside compatdata) |
| 309 | pub fn get_prefix_path(&self) -> Option<PathBuf> { |
| 310 | self.compat_data_path.as_ref().map(|p| p.join("pfx")) |
| 311 | } |
| 312 | |
| 313 | /// Format size as human-readable string |
| 314 | pub fn size_human(&self) -> String { |
| 315 | const KB: u64 = 1024; |
| 316 | const MB: u64 = KB * 1024; |
| 317 | const GB: u64 = MB * 1024; |
| 318 | |
| 319 | if self.size_on_disk >= GB { |
| 320 | format!("{:.1} GB", self.size_on_disk as f64 / GB as f64) |
| 321 | } else if self.size_on_disk >= MB { |
| 322 | format!("{:.1} MB", self.size_on_disk as f64 / MB as f64) |
| 323 | } else if self.size_on_disk >= KB { |
| 324 | format!("{:.1} KB", self.size_on_disk as f64 / KB as f64) |
| 325 | } else { |
| 326 | format!("{} B", self.size_on_disk) |
| 327 | } |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | #[cfg(test)] |
| 332 | mod tests { |
| 333 | use super::*; |
| 334 | |
| 335 | #[test] |
| 336 | fn test_size_human() { |
| 337 | let app = SteamApp { |
| 338 | app_id: 1, |
| 339 | name: "Test".to_string(), |
| 340 | install_dir: "test".to_string(), |
| 341 | install_path: PathBuf::new(), |
| 342 | library_path: PathBuf::new(), |
| 343 | size_on_disk: 50 * 1024 * 1024 * 1024, // 50 GB |
| 344 | compat_data_path: None, |
| 345 | }; |
| 346 | assert_eq!(app.size_human(), "50.0 GB"); |
| 347 | } |
| 348 | } |
| 349 |