Rust · 10108 bytes Raw Blame History
1 //! Proton version detection and management
2
3 use crate::config::WandaConfig;
4 use crate::error::{Result, WandaError};
5 use crate::steam::SteamInstallation;
6 use std::cmp::Ordering;
7 use std::path::{Path, PathBuf};
8 use tracing::{debug, info, warn};
9
10 /// Proton compatibility level with WeMod
11 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
12 pub enum ProtonCompatibility {
13 /// Known to work well with WeMod (GE-Proton 8+)
14 Recommended,
15 /// Should work with WeMod
16 Supported,
17 /// May work but not tested
18 Experimental,
19 /// Known to have issues
20 Unsupported,
21 }
22
23 /// A detected Proton installation
24 #[derive(Debug, Clone)]
25 pub struct ProtonVersion {
26 /// Display name (e.g., "GE-Proton9-5", "Proton 8.0")
27 pub name: String,
28 /// Path to the Proton installation
29 pub path: PathBuf,
30 /// Parsed version numbers (major, minor, patch)
31 pub version: (u32, u32, u32),
32 /// Whether this is a GE-Proton build
33 pub is_ge: bool,
34 /// Whether this is Proton Experimental
35 pub is_experimental: bool,
36 /// Compatibility level with WeMod
37 pub compatibility: ProtonCompatibility,
38 }
39
40 impl ProtonVersion {
41 /// Get the path to the proton executable
42 pub fn proton_exe(&self) -> PathBuf {
43 self.path.join("proton")
44 }
45
46 /// Get the path to the Wine binary
47 pub fn wine_exe(&self) -> PathBuf {
48 self.path.join("files/bin/wine64")
49 }
50 }
51
52 impl PartialOrd for ProtonVersion {
53 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
54 Some(self.cmp(other))
55 }
56 }
57
58 impl Ord for ProtonVersion {
59 fn cmp(&self, other: &Self) -> Ordering {
60 // Prefer GE-Proton over standard
61 match (self.is_ge, other.is_ge) {
62 (true, false) => return Ordering::Greater,
63 (false, true) => return Ordering::Less,
64 _ => {}
65 }
66 // Then compare by version
67 self.version.cmp(&other.version)
68 }
69 }
70
71 impl PartialEq for ProtonVersion {
72 fn eq(&self, other: &Self) -> bool {
73 self.name == other.name && self.path == other.path
74 }
75 }
76
77 impl Eq for ProtonVersion {}
78
79 /// Manages Proton version discovery and selection
80 pub struct ProtonManager {
81 /// All discovered Proton versions
82 pub versions: Vec<ProtonVersion>,
83 }
84
85 impl ProtonManager {
86 /// Discover all Proton versions on the system
87 pub fn discover(steam: &SteamInstallation, config: &WandaConfig) -> Result<Self> {
88 let mut versions = Vec::new();
89
90 // Search in Steam's common folder (official Proton)
91 let common_dir = steam.root_path.join("steamapps/common");
92 Self::scan_directory(&common_dir, &mut versions);
93
94 // Search in compatibility tools directory (GE-Proton, custom builds)
95 let compat_tools_dir = steam.root_path.join("compatibilitytools.d");
96 Self::scan_directory(&compat_tools_dir, &mut versions);
97
98 // System-wide compatibility tools
99 let system_compat = PathBuf::from("/usr/share/steam/compatibilitytools.d");
100 if system_compat.exists() {
101 Self::scan_directory(&system_compat, &mut versions);
102 }
103
104 // User-configured additional paths
105 for path in &config.proton.search_paths {
106 if path.exists() {
107 Self::scan_directory(path, &mut versions);
108 }
109 }
110
111 // Sort by preference (GE-Proton first, then by version descending)
112 versions.sort_by(|a, b| b.cmp(a));
113
114 info!("Discovered {} Proton versions", versions.len());
115 for v in &versions {
116 debug!(
117 " {} ({:?}) at {}",
118 v.name,
119 v.compatibility,
120 v.path.display()
121 );
122 }
123
124 Ok(Self { versions })
125 }
126
127 /// Scan a directory for Proton installations
128 fn scan_directory(dir: &Path, versions: &mut Vec<ProtonVersion>) {
129 let entries = match std::fs::read_dir(dir) {
130 Ok(e) => e,
131 Err(_) => return,
132 };
133
134 for entry in entries.flatten() {
135 let path = entry.path();
136 if path.is_dir() {
137 if let Some(version) = Self::detect_proton(&path) {
138 versions.push(version);
139 }
140 }
141 }
142 }
143
144 /// Detect if a directory contains a Proton installation
145 fn detect_proton(path: &Path) -> Option<ProtonVersion> {
146 // Proton directories should have a 'proton' script
147 let proton_script = path.join("proton");
148 if !proton_script.exists() {
149 return None;
150 }
151
152 let name = path.file_name()?.to_str()?.to_string();
153
154 // Parse version from name
155 let (version, is_ge, is_experimental) = Self::parse_version_from_name(&name);
156 let compatibility = Self::determine_compatibility(&name, version, is_ge, is_experimental);
157
158 Some(ProtonVersion {
159 name,
160 path: path.to_path_buf(),
161 version,
162 is_ge,
163 is_experimental,
164 compatibility,
165 })
166 }
167
168 /// Parse version numbers from Proton directory name
169 fn parse_version_from_name(name: &str) -> ((u32, u32, u32), bool, bool) {
170 let is_ge = name.contains("GE-Proton") || name.contains("Proton-GE");
171 let is_experimental = name.contains("Experimental");
172
173 // Try to extract version numbers
174 // GE-Proton format: "GE-Proton9-5" -> (9, 5, 0)
175 // Standard format: "Proton 8.0-5" -> (8, 0, 5)
176 // Experimental: "Proton - Experimental" -> (999, 0, 0) to sort high
177
178 if is_experimental {
179 return ((999, 0, 0), is_ge, is_experimental);
180 }
181
182 let version = Self::extract_version_numbers(name);
183 (version, is_ge, is_experimental)
184 }
185
186 /// Extract version numbers from a string
187 fn extract_version_numbers(s: &str) -> (u32, u32, u32) {
188 let numbers: Vec<u32> = s
189 .chars()
190 .filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-')
191 .collect::<String>()
192 .split(|c| c == '.' || c == '-')
193 .filter_map(|n| n.parse().ok())
194 .collect();
195
196 match numbers.as_slice() {
197 [major, minor, patch, ..] => (*major, *minor, *patch),
198 [major, minor] => (*major, *minor, 0),
199 [major] => (*major, 0, 0),
200 [] => (0, 0, 0),
201 }
202 }
203
204 /// Determine compatibility level based on version info
205 fn determine_compatibility(
206 name: &str,
207 version: (u32, u32, u32),
208 is_ge: bool,
209 is_experimental: bool,
210 ) -> ProtonCompatibility {
211 // GE-Proton 8+ is recommended for WeMod
212 if is_ge && version.0 >= 8 {
213 return ProtonCompatibility::Recommended;
214 }
215
216 // GE-Proton 7.x should work
217 if is_ge && version.0 >= 7 {
218 return ProtonCompatibility::Supported;
219 }
220
221 // Proton Experimental might work
222 if is_experimental {
223 return ProtonCompatibility::Experimental;
224 }
225
226 // Standard Proton 8+ should work
227 if version.0 >= 8 {
228 return ProtonCompatibility::Supported;
229 }
230
231 // Wine-GE has known issues
232 if name.contains("Wine-GE") {
233 return ProtonCompatibility::Unsupported;
234 }
235
236 // Older versions might have issues
237 if version.0 < 7 {
238 return ProtonCompatibility::Unsupported;
239 }
240
241 ProtonCompatibility::Experimental
242 }
243
244 /// Get the recommended Proton version for WeMod
245 pub fn get_recommended(&self) -> Option<&ProtonVersion> {
246 // First try to find a Recommended version
247 if let Some(v) = self
248 .versions
249 .iter()
250 .find(|v| v.compatibility == ProtonCompatibility::Recommended)
251 {
252 return Some(v);
253 }
254
255 // Fall back to Supported
256 if let Some(v) = self
257 .versions
258 .iter()
259 .find(|v| v.compatibility == ProtonCompatibility::Supported)
260 {
261 return Some(v);
262 }
263
264 // Fall back to Experimental
265 self.versions
266 .iter()
267 .find(|v| v.compatibility == ProtonCompatibility::Experimental)
268 }
269
270 /// Find a Proton version by name
271 pub fn find_by_name(&self, name: &str) -> Option<&ProtonVersion> {
272 let name_lower = name.to_lowercase();
273 self.versions
274 .iter()
275 .find(|v| v.name.to_lowercase().contains(&name_lower))
276 }
277
278 /// Get a version by preference: user-specified, then recommended
279 pub fn get_preferred(&self, config: &WandaConfig) -> Result<&ProtonVersion> {
280 // Check user preference first
281 if let Some(ref preferred) = config.proton.preferred_version {
282 if let Some(v) = self.find_by_name(preferred) {
283 if v.compatibility == ProtonCompatibility::Unsupported {
284 warn!(
285 "Preferred Proton version {} has known compatibility issues",
286 v.name
287 );
288 }
289 return Ok(v);
290 }
291 warn!(
292 "Preferred Proton version '{}' not found, using recommended",
293 preferred
294 );
295 }
296
297 self.get_recommended().ok_or(WandaError::ProtonNotFound)
298 }
299 }
300
301 #[cfg(test)]
302 mod tests {
303 use super::*;
304
305 #[test]
306 fn test_version_parsing() {
307 let (v, is_ge, _) = ProtonManager::parse_version_from_name("GE-Proton9-5");
308 assert_eq!(v, (9, 5, 0));
309 assert!(is_ge);
310
311 let (v, is_ge, _) = ProtonManager::parse_version_from_name("Proton 8.0-5");
312 assert_eq!(v, (8, 0, 5));
313 assert!(!is_ge);
314 }
315
316 #[test]
317 fn test_compatibility() {
318 let compat =
319 ProtonManager::determine_compatibility("GE-Proton9-5", (9, 5, 0), true, false);
320 assert_eq!(compat, ProtonCompatibility::Recommended);
321
322 let compat =
323 ProtonManager::determine_compatibility("Proton 6.0", (6, 0, 0), false, false);
324 assert_eq!(compat, ProtonCompatibility::Unsupported);
325 }
326 }
327