77import time
88from datetime import datetime
99from config_manager import ConfigManager
10+ import logging
11+
1012class ProtonManager :
1113 def __init__ (self ):
1214 self .protons_dir = ConfigManager .protons_dir
1315 os .makedirs (self .protons_dir , exist_ok = True )
16+ self .available_ge_cache = None
17+ self .available_official_stable_cache = None
18+ self .available_official_exp_cache = None
19+ self .cache_time = 0
20+ self .cache_duration = 3600 # 1 hour
21+
22+ def refresh_cache_if_needed (self ):
23+ if time .time () - self .cache_time > self .cache_duration :
24+ self .available_ge_cache = self .get_available_ge ()
25+ self .available_official_stable_cache = self .get_available_official (stable = True )
26+ self .available_official_exp_cache = self .get_available_official (stable = False )
27+ self .cache_time = time .time ()
28+
1429 def get_installed_protons (self ):
1530 protons = []
1631 for d in os .listdir (self .protons_dir ):
@@ -23,47 +38,113 @@ def get_installed_protons(self):
2338 status = 'Update Available' if update_info else 'Installed'
2439 protons .append ({'version' : version , 'type' : proton_type , 'date' : install_date , 'status' : status })
2540 return sorted (protons , key = lambda x : x ['version' ])
41+
2642 def get_proton_path (self , version ):
27- return os .path .join (self .protons_dir , version , 'proton' )
43+ base = os .path .join (self .protons_dir , version )
44+ possible_paths = [
45+ os .path .join (base , 'proton' ),
46+ os .path .join (base , 'dist' , 'bin' , 'proton' ),
47+ os .path .join (base , 'bin' , 'proton' ),
48+ os .path .join (base , 'proton-run' )
49+ ]
50+ for path in possible_paths :
51+ if os .path .exists (path ):
52+ return path
53+ raise Exception (f"Proton binary not found in { version } " )
54+
55+ def _version_key (self , version ):
56+ # Improved version sorting: handle numbers and non-numbers, including decimals
57+ version = version .replace ('GE-Proton' , '' ).replace ('Proton-' , '' )
58+ parts = []
59+ current = ''
60+ for char in version :
61+ if char .isdigit () or char == '.' :
62+ if current and not (current [- 1 ].isdigit () or current [- 1 ] == '.' ):
63+ parts .append (current )
64+ current = ''
65+ current += char
66+ else :
67+ if current and (current [- 1 ].isdigit () or current [- 1 ] == '.' ):
68+ parts .append (current )
69+ current = ''
70+ current += char
71+ if current :
72+ parts .append (current )
73+ # Convert parts: numbers to float (for decimals like 10.0), others as strings
74+ def convert_part (part ):
75+ try :
76+ return float (part ) if '.' in part else int (part )
77+ except ValueError :
78+ return part
79+ return [convert_part (part ) for part in parts ]
80+
2881 def get_available_ge (self ):
82+ if self .available_ge_cache is not None :
83+ return self .available_ge_cache
2984 for attempt in range (3 ):
3085 try :
3186 url = 'https://api.github.com/repos/GloriousEggroll/proton-ge-custom/releases'
32- response = requests .get (url , timeout = 10 )
87+ response = requests .get (url , timeout = 15 ) # Increased timeout
3388 response .raise_for_status ()
3489 releases = json .loads (response .text )
35- return [r ['tag_name' ] for r in releases if 'tag_name' in r and r ['tag_name' ].startswith ('GE-Proton' )]
90+ tags = [r ['tag_name' ] for r in releases if 'tag_name' in r and r ['tag_name' ].startswith ('GE-Proton' )]
91+ tags .sort (key = self ._version_key , reverse = True )
92+ self .available_ge_cache = tags
93+ return tags
3694 except Exception as e :
37- print (f"Error fetching GE protons (attempt { attempt + 1 } /3): { e } " )
38- time .sleep (2 )
95+ logging .error (f"Error fetching GE protons (attempt { attempt + 1 } /3): { e } " )
96+ time .sleep (3 ) # Slightly longer delay
97+ logging .warning ("Failed to fetch GE protons after retries, returning empty list" )
98+ self .available_ge_cache = []
3999 return []
40- def get_available_official (self ):
100+
101+ def get_available_official (self , stable = True ):
102+ if stable and self .available_official_stable_cache is not None :
103+ return self .available_official_stable_cache
104+ if not stable and self .available_official_exp_cache is not None :
105+ return self .available_official_exp_cache
41106 for attempt in range (3 ):
42107 try :
43108 url = 'https://api.github.com/repos/ValveSoftware/Proton/releases'
44- response = requests .get (url , timeout = 10 )
109+ response = requests .get (url , timeout = 15 ) # Increased timeout
45110 response .raise_for_status ()
46111 releases = json .loads (response .text )
47- return [r ['tag_name' ] for r in releases if 'tag_name' in r and ('experimental' in r ['tag_name' ].lower () or 'hotfix' in r ['tag_name' ].lower () or r ['tag_name' ].startswith ('Proton-' ))]
112+ filtered = [r ['tag_name' ] for r in releases if 'tag_name' in r ]
113+ if stable :
114+ filtered = [t for t in filtered if 'experimental' not in t .lower () and 'hotfix' not in t .lower ()]
115+ else :
116+ filtered = [t for t in filtered if 'experimental' in t .lower () or 'hotfix' in t .lower ()]
117+ filtered .sort (key = self ._version_key , reverse = True )
118+ if stable :
119+ self .available_official_stable_cache = filtered
120+ else :
121+ self .available_official_exp_cache = filtered
122+ return filtered
48123 except Exception as e :
49- print (f"Error fetching official protons (attempt { attempt + 1 } /3): { e } " )
50- time .sleep (2 )
124+ logging .error (f"Error fetching official protons (attempt { attempt + 1 } /3): { e } " )
125+ time .sleep (3 )
126+ logging .warning (f"Failed to fetch { 'stable' if stable else 'experimental' } official protons after retries, returning empty list" )
127+ if stable :
128+ self .available_official_stable_cache = []
129+ else :
130+ self .available_official_exp_cache = []
51131 return []
132+
52133 def install_proton (self , version , proton_type , progress_callback = None ):
53134 try :
54135 repo = 'GloriousEggroll/proton-ge-custom' if proton_type == 'GE' else 'ValveSoftware/Proton'
55136 url = f'https://api.github.com/repos/{ repo } /releases'
56- response = requests .get (url , timeout = 10 )
137+ response = requests .get (url , timeout = 15 )
57138 response .raise_for_status ()
58139 releases = json .loads (response .text )
59140 selected_release = next ((r for r in releases if r ['tag_name' ] == version ), None )
60141 if not selected_release :
61- print (f"No release found for { version } " )
142+ logging . error (f"No release found for { version } " )
62143 return False , f"No release found for { version } "
63144 assets = selected_release ['assets' ]
64145 tar_asset = next ((a for a in assets if a ['name' ].endswith ('.tar.gz' )), None )
65146 if not tar_asset :
66- print (f"No tar.gz asset found for { version } " )
147+ logging . error (f"No tar.gz asset found for { version } " )
67148 return False , f"No tar.gz asset found for { version } "
68149 dl_url = tar_asset ['browser_download_url' ]
69150 progress_callback ("Downloading" , 0 , 100 )
@@ -82,67 +163,106 @@ def install_proton(self, version, proton_type, progress_callback=None):
82163 progress_callback ("Downloading" , downloaded , total_size )
83164 temp_tar_path = temp_tar .name
84165 progress_callback ("Extracting" , 0 , 100 )
166+ extract_dir = os .path .join (self .protons_dir , version )
167+ os .makedirs (extract_dir , exist_ok = True )
85168 with tarfile .open (temp_tar_path ) as tar :
169+ for member in tar .getmembers ():
170+ member_path = os .path .join (extract_dir , member .name )
171+ abs_path = os .path .abspath (member_path )
172+ if not abs_path .startswith (os .path .abspath (extract_dir )):
173+ raise Exception ("Path traversal detected in tar file" )
86174 total_size = sum (m .size for m in tar .getmembers ())
87175 extracted = 0
88176 for member in tar .getmembers ():
89- tar .extract (member , self . protons_dir )
177+ tar .extract (member , extract_dir )
90178 extracted += member .size
91179 if progress_callback and total_size :
92180 progress_callback ("Extracting" , extracted , total_size )
181+ subdirs = [d for d in os .listdir (extract_dir ) if os .path .isdir (os .path .join (extract_dir , d ))]
182+ if len (subdirs ) == 1 :
183+ subdir = os .path .join (extract_dir , subdirs [0 ])
184+ for item in os .listdir (subdir ):
185+ shutil .move (os .path .join (subdir , item ), extract_dir )
186+ os .rmdir (subdir )
93187 os .remove (temp_tar_path )
94- # Verify proton binary exists
95- proton_path = os .path .join (self .protons_dir , version , 'proton' )
96- if not os .path .exists (proton_path ):
188+ if not os .path .exists (self .get_proton_path (version )):
189+ shutil .rmtree (extract_dir )
97190 return False , f"Proton binary not found after extraction for { version } "
191+ logging .info (f"Installed Proton { version } ({ proton_type } )" )
98192 return True , "Success"
99193 except Exception as e :
100- print (f"Error installing { proton_type } proton { version } : { e } " )
194+ logging . error (f"Error installing { proton_type } proton { version } : { e } " )
101195 return False , str (e )
196+
102197 def install_custom_tar (self , tar_path , version , progress_callback = None ):
103198 try :
199+ extract_dir = os .path .join (self .protons_dir , version )
200+ os .makedirs (extract_dir , exist_ok = True )
104201 progress_callback ("Extracting" , 0 , 100 )
105202 with tarfile .open (tar_path ) as tar :
203+ for member in tar .getmembers ():
204+ member_path = os .path .join (extract_dir , member .name )
205+ abs_path = os .path .abspath (member_path )
206+ if not abs_path .startswith (os .path .abspath (extract_dir )):
207+ raise Exception ("Path traversal detected in tar file" )
106208 total_size = sum (m .size for m in tar .getmembers ())
107209 extracted = 0
108210 for member in tar .getmembers ():
109- tar .extract (member , self . protons_dir )
211+ tar .extract (member , extract_dir )
110212 extracted += member .size
111213 if progress_callback and total_size :
112214 progress_callback ("Extracting" , extracted , total_size )
113- proton_path = os .path .join (self .protons_dir , version , 'proton' )
114- if not os .path .exists (proton_path ):
215+ subdirs = [d for d in os .listdir (extract_dir ) if os .path .isdir (os .path .join (extract_dir , d ))]
216+ if len (subdirs ) == 1 :
217+ subdir = os .path .join (extract_dir , subdirs [0 ])
218+ for item in os .listdir (subdir ):
219+ shutil .move (os .path .join (subdir , item ), extract_dir )
220+ os .rmdir (subdir )
221+ if not os .path .exists (self .get_proton_path (version )):
222+ shutil .rmtree (extract_dir )
115223 return False , f"Proton binary not found after extraction for { version } "
224+ logging .info (f"Installed custom Proton { version } from tar" )
116225 return True , "Success"
117226 except Exception as e :
118- print (f"Error installing custom tar: { e } " )
227+ logging . error (f"Error installing custom tar: { e } " )
119228 return False , str (e )
229+
120230 def install_custom_folder (self , src_folder , version ):
121231 try :
122232 dest = os .path .join (self .protons_dir , version )
123- shutil .copytree (src_folder , dest )
124- proton_path = os .path .join (self .protons_dir , version , 'proton' )
125- if not os .path .exists (proton_path ):
233+ shutil .copytree (src_folder , dest , dirs_exist_ok = True )
234+ if not os .path .exists (self .get_proton_path (version )):
126235 shutil .rmtree (dest )
127236 return False , f"Proton binary not found in folder for { version } "
237+ logging .info (f"Installed custom Proton { version } from folder" )
128238 return True , "Success"
129239 except Exception as e :
130- print (f"Error installing custom folder: { e } " )
240+ logging . error (f"Error installing custom folder: { e } " )
131241 return False , str (e )
242+
132243 def remove_proton (self , version ):
244+ path = os .path .join (self .protons_dir , version )
245+ if not os .path .exists (path ):
246+ return False
133247 try :
134- shutil .rmtree (os .path .join (self .protons_dir , version ))
248+ shutil .rmtree (path )
249+ logging .info (f"Removed Proton { version } " )
135250 return True
136251 except Exception as e :
137- print (f"Error removing proton: { e } " )
252+ logging . error (f"Error removing proton: { e } " )
138253 return False
254+
139255 def check_update (self , version , proton_type ):
256+ self .refresh_cache_if_needed ()
140257 if proton_type == 'GE' :
141- available = self .get_available_ge ()
258+ available = self .available_ge_cache
142259 if available and available [0 ] != version :
143260 return ('GE' , available [0 ])
144261 elif proton_type == 'Official' :
145- available = self .get_available_official ()
262+ available_stable = self .available_official_stable_cache
263+ available_exp = self .available_official_exp_cache
264+ available = available_stable + available_exp
146265 if available and available [0 ] != version :
147- return ('Official' , available [0 ])
266+ new_type = 'Official' if available [0 ] in available_stable else 'Experimental'
267+ return (new_type , available [0 ])
148268 return None
0 commit comments