Rust · 11183 bytes Raw Blame History
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