import os import shutil import tempfile import time import libarchive import requests import orjson from PySide6.QtWidgets import (QDialog, QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, QCheckBox, QPushButton, QHeaderView, QMessageBox, QLabel, QTextEdit, QHBoxLayout, QProgressBar, QFrame, QSizePolicy, QAbstractItemView) from PySide6.QtCore import Qt, QThread, Signal, QMutex, QWaitCondition, QTimer import urllib.parse from portprotonqt.config_utils import read_proxy_config, get_portproton_start_command from portprotonqt.logger import get_logger from portprotonqt.localization import _ from portprotonqt.version_utils import version_sort_key logger = get_logger(__name__) def get_cpu_level(): """ Determine CPU level based on feature flags Returns: int: CPU level (0-4) based on supported instruction sets """ try: with open('/proc/cpuinfo') as f: # Read line by line to find flags without loading entire file for line in f: if line.startswith('flags'): # Extract the actual flags flags = set(line.split(':')[1].strip().split()) break else: # If no flags line found logger.warning("Could not find flags in /proc/cpuinfo, defaulting to CPU level 4") return 4 except FileNotFoundError: logger.warning("Could not read /proc/cpuinfo, defaulting to CPU level 4") return 4 # Default to highest level if we can't read cpuinfo # Check for required flags for each level using a more efficient approach # Pre-define the flag requirements for each level level1_required = {'lm', 'cmov', 'cx8', 'fpu', 'fxsr', 'mmx', 'syscall', 'sse2'} if not level1_required.issubset(flags): return 0 level2_required = {'cx16', 'lahf_lm', 'popcnt', 'sse4_1', 'sse4_2', 'ssse3'} if not level2_required.issubset(flags): return 1 level3_required = {'avx', 'avx2', 'bmi1', 'bmi2', 'f16c', 'fma', 'abm', 'movbe', 'xsave'} if not level3_required.issubset(flags): return 2 level4_required = {'avx512f', 'avx512bw', 'avx512cd', 'avx512dq', 'avx512vl'} if level4_required.issubset(flags): return 4 return 3 class DownloadThread(QThread): progress = Signal(int) finished = Signal(str, bool) error = Signal(str) def __init__(self, download_url, filename): super().__init__() self.download_url = download_url self.filename = filename self._is_running = True self._mutex = QMutex() self._condition = QWaitCondition() def run(self): try: # Create a session with proxy support session = requests.Session() proxy = read_proxy_config() or {} if proxy: session.proxies.update(proxy) session.verify = True response = session.get(self.download_url, stream=True) response.raise_for_status() total_size = int(response.headers.get('content-length', 0)) downloaded_size = 0 with open(self.filename, 'wb') as file: for chunk in response.iter_content(chunk_size=8192): self._mutex.lock() if not self._is_running: self._mutex.unlock() # Если загрузка отменена, удаляем частично скачанный файл if os.path.exists(self.filename): os.remove(self.filename) return self._mutex.unlock() if chunk: file.write(chunk) downloaded_size += len(chunk) if total_size > 0: progress = int((downloaded_size / total_size) * 100) self.progress.emit(progress) self._mutex.lock() if self._is_running: self._mutex.unlock() self.finished.emit(self.filename, True) else: self._mutex.unlock() # Если загрузка отменена в последний момент, удаляем файл if os.path.exists(self.filename): os.remove(self.filename) except Exception as e: self._mutex.lock() if self._is_running: self._mutex.unlock() # Удаляем частично скачанный файл при ошибке if os.path.exists(self.filename): try: os.remove(self.filename) except OSError: pass self.error.emit(str(e)) else: self._mutex.unlock() def stop(self): """Безопасная остановка потока""" self._mutex.lock() self._is_running = False self._mutex.unlock() if self.isRunning(): self.quit() if not self.wait(1000): # Ждем до 1 секунды logger.warning("Thread did not stop gracefully, but continuing...") class ExtractionThread(QThread): progress = Signal(int) # % speed = Signal(float) # MB/s eta = Signal(int) # seconds finished = Signal(str, bool) # archive_path, success error = Signal(str) def __init__(self, archive_path: str, extract_dir: str): super().__init__() self.archive_path = archive_path self.extract_dir = extract_dir self._is_running = True self._mutex = QMutex() # ======================== # Internal helpers # ======================== def _should_stop(self) -> bool: self._mutex.lock() running = self._is_running self._mutex.unlock() return not running # ======================== # Main logic # ======================== def run(self): try: self.progress.emit(0) self.speed.emit(0.0) self.eta.emit(0) os.makedirs(self.extract_dir, exist_ok=True) archive_size = os.path.getsize(self.archive_path) if archive_size <= 0: archive_size = 1 start_time = time.monotonic() last_emit_time = start_time last_progress = -1 last_bytes_read = 0 # Меняем рабочую директорию для корректной распаковки original_dir = os.getcwd() old_umask = os.umask(0) # Сохраняем и сбрасываем umask os.chdir(self.extract_dir) try: # Список для отложенной установки времени модификации deferred_times = [] with libarchive.file_reader(self.archive_path) as archive: for entry in archive: if self._should_stop(): return entry_path = entry.pathname # Создаём директории if entry.isdir: os.makedirs(entry_path, exist_ok=True) # Права для директорий if entry.mode: try: os.chmod(entry_path, entry.mode) except (OSError, PermissionError): pass # Откладываем установку времени для директорий if entry.mtime: deferred_times.append((entry_path, entry.mtime)) # Извлекаем файлы elif entry.isfile: parent_dir = os.path.dirname(entry_path) if parent_dir: os.makedirs(parent_dir, exist_ok=True) # Записываем содержимое файла with open(entry_path, 'wb') as f: for block in entry.get_blocks(): if self._should_stop(): return f.write(block) # Устанавливаем права (включая execute bit) if entry.mode: try: os.chmod(entry_path, entry.mode) except (OSError, PermissionError): pass # Устанавливаем время модификации if entry.mtime: try: os.utime(entry_path, (entry.mtime, entry.mtime)) except (OSError, PermissionError): pass # Символические ссылки elif entry.issym: parent_dir = os.path.dirname(entry_path) if parent_dir: os.makedirs(parent_dir, exist_ok=True) if os.path.lexists(entry_path): os.remove(entry_path) try: os.symlink(entry.linkpath, entry_path) except (OSError, NotImplementedError): pass # Обновляем прогресс bytes_read = archive.bytes_read now = time.monotonic() elapsed = now - start_time if bytes_read != last_bytes_read: last_bytes_read = bytes_read if now - last_emit_time >= 0.1 or elapsed < 0.1: progress = int((bytes_read / archive_size) * 100) if progress != last_progress: self.progress.emit(min(progress, 99)) last_progress = progress if elapsed > 0: speed = (bytes_read / (1024 * 1024)) / elapsed self.speed.emit(round(speed, 2)) if speed > 0: remaining_mb = (archive_size - bytes_read) / (1024 * 1024) self.eta.emit(max(0, int(remaining_mb / speed))) else: self.eta.emit(0) else: self.speed.emit(0.0) self.eta.emit(0) last_emit_time = now # Устанавливаем время модификации для директорий в обратном порядке # (чтобы родительские директории обновлялись последними) for dir_path, mtime in reversed(deferred_times): try: os.utime(dir_path, (mtime, mtime)) except (OSError, PermissionError): pass self.progress.emit(100) self.speed.emit(0.0) self.eta.emit(0) self.finished.emit(self.archive_path, True) finally: os.chdir(original_dir) os.umask(old_umask) # Восстанавливаем umask except Exception as e: if not self._should_stop(): self.error.emit(str(e)) # ======================== # Thread control # ======================== def stop(self): self._mutex.lock() self._is_running = False self._mutex.unlock() if self.isRunning(): self.quit() self.wait(1000) class ProtonManager(QDialog): def __init__(self, parent=None, portproton_location=None): super().__init__(parent) self.selected_assets = {} # {unique_id: asset_data} self.current_extraction_thread = None self.current_download_thread = None # Still keep this for compatibility with Downloader's thread self.is_downloading = False # Actually extraction in progress now self.assets_to_download = [] self.current_download_index = 0 self.portproton_location = portproton_location self.initUI() self.load_proton_data_from_json() def initUI(self): self.setWindowTitle(_('Get other Wine')) self.resize(800, 600) layout = QVBoxLayout(self) layout.setContentsMargins(5, 5, 5, 5) layout.setSpacing(5) # Tab widget - основной растягивающийся элемент self.tab_widget = QTabWidget() self.tab_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) layout.addWidget(self.tab_widget, 1) # Инфо-блок для показа выбранного (компактный для информации по выбранным закачкам) selection_widget = QWidget() selection_layout = QVBoxLayout(selection_widget) selection_layout.setContentsMargins(0, 2, 0, 2) selection_layout.setSpacing(2) selection_label = QLabel(_("Selected assets:")) selection_label.setMaximumHeight(20) selection_layout.addWidget(selection_label) self.selection_text = QTextEdit() self.selection_text.setMaximumHeight(80) self.selection_text.setReadOnly(True) self.selection_text.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) self.selection_text.setPlainText(_("No assets selected")) selection_layout.addWidget(self.selection_text) layout.addWidget(selection_widget) # Область прогресса загрузки self.download_frame = QFrame() self.download_frame.setFrameStyle(QFrame.Shape.StyledPanel) self.download_frame.setVisible(False) self.download_frame.setMaximumHeight(80) download_layout = QVBoxLayout(self.download_frame) download_layout.setContentsMargins(10, 5, 10, 5) download_layout.setSpacing(5) self.download_info_label = QLabel(_("Downloading: ")) download_layout.addWidget(self.download_info_label) progress_layout = QHBoxLayout() self.download_progress = QProgressBar() self.download_progress.setMinimum(0) self.download_progress.setMaximum(100) self.cancel_btn = QPushButton(_('Cancel')) self.cancel_btn.clicked.connect(self.cancel_current_download) progress_layout.addWidget(self.download_progress, 4) progress_layout.addWidget(self.cancel_btn, 1) download_layout.addLayout(progress_layout) layout.addWidget(self.download_frame) # Кнопки управления button_layout = QHBoxLayout() self.download_btn = QPushButton(_('Download Selected')) self.download_btn.clicked.connect(self.download_selected) self.download_btn.setEnabled(False) self.download_btn.setMinimumHeight(40) self.clear_btn = QPushButton(_('Clear All')) self.clear_btn.clicked.connect(self.clear_selection) self.clear_btn.setMinimumHeight(40) button_layout.addWidget(self.download_btn) button_layout.addWidget(self.clear_btn) layout.addLayout(button_layout) def load_proton_data_from_json(self): """Загружаем данные по Протонам из файла JSON""" json_url = "https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata.json" try: logger.debug(f"Loading JSON metadata from: {json_url}") # Create a session with proxy support session = requests.Session() proxy = read_proxy_config() or {} if proxy: session.proxies.update(proxy) session.verify = True response = session.get(json_url, timeout=30) response.raise_for_status() metadata = orjson.loads(response.content) logger.info(f"Successfully loaded JSON metadata with {len(metadata)} entries") self.process_metadata(metadata) except requests.exceptions.RequestException as e: logger.error(f"Network error loading JSON: {e}") except orjson.JSONDecodeError as e: logger.error(f"JSON parsing error: {e}") except Exception as e: logger.error(f"Error loading metadata: {e}") def process_metadata(self, metadata): """Обработка JSON, создание Табов""" successful_tabs = 0 # Get CPU level to filter incompatible versions self.cpu_level = get_cpu_level() logger.info(f"Detected CPU level: {self.cpu_level}") # Собираем табы в словарь для сортировки tabs_dict = {} for source_key, entries in metadata.items(): # Пропускаем таб "gdk_proton" (вроде ненужный протон, скипаем) if source_key.lower() == 'gdk_proton': logger.debug(f"Skipping tab: {source_key}") continue # Filter entries based on CPU compatibility filtered_entries = self.filter_entries_by_cpu_level(entries, source_key) tabs_dict[source_key] = filtered_entries # Proton_LG в самое начало кидаем if 'proton_lg' in tabs_dict: if self.create_tab_from_entries('proton_lg', tabs_dict['proton_lg']): successful_tabs += 1 del tabs_dict['proton_lg'] # Остальные табы после Proton_LG, сортируем по алфавиту for source_key in sorted(tabs_dict.keys()): entries = tabs_dict[source_key] if self.create_tab_from_entries(source_key, entries): successful_tabs += 1 return successful_tabs def filter_entries_by_cpu_level(self, entries, source_name): """Filter entries based on CPU compatibility""" if self.cpu_level >= 4: # If CPU supports all features, return all entries return entries filtered_entries = [] for entry in entries: # Get the filename from the entry url = entry.get('url', '') filename = entry.get('name', '') if url: parsed_url = urllib.parse.urlparse(url) url_filename = os.path.basename(parsed_url.path) if url_filename: filename = url_filename # Check if the filename contains version indicators that require specific CPU levels should_include = True # Check for v2, v3, v4 indicators in the filename if 'v2' in filename and self.cpu_level < 2: should_include = False elif 'v3' in filename and self.cpu_level < 3: should_include = False elif 'v4' in filename and self.cpu_level < 4: should_include = False if should_include: filtered_entries.append(entry) logger.info(f"Filtered {len(entries)} -> {len(filtered_entries)} entries for {source_name} based on CPU level {self.cpu_level}") return filtered_entries def create_tab_from_entries(self, source_name, entries): """Создаем вкладку с таблицей для источника Proton из записей JSON""" try: logger.debug(f"Processing {len(entries)} entries for source: {source_name}") tab = QWidget() tab.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) layout = QVBoxLayout(tab) layout.setContentsMargins(2, 2, 2, 2) layout.setSpacing(2) table = QTableWidget() table.verticalHeader().setVisible(False) table.setColumnCount(2) # Только Checkbox и Имя table.setHorizontalHeaderLabels(['', _('Asset Name')]) table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) header = table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) # Include all entries (both installed and non-installed) all_entries = [] for entry in entries: # Извлекаем имя файла из URL url = entry.get('url', '') if url: parsed_url = urllib.parse.urlparse(url) url_filename = os.path.basename(parsed_url.path) if url_filename: entry['filename'] = url_filename all_entries.append(entry) # Sort entries by version before displaying all_entries.sort(key=version_sort_key) table.setRowCount(len(all_entries)) table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) table.cellClicked.connect(self.on_cell_clicked) for row_index, entry in enumerate(all_entries): self.add_asset_row_from_json(table, row_index, entry, source_name) layout.addWidget(table, 1) tab_name = (self.get_short_source_name(source_name) or "UNKNOWN").upper() # Название для Таба в верхний регистр self.tab_widget.addTab(tab, tab_name) logger.info(f"Successfully created tab for {source_name} with {len(all_entries)} assets (filtered from {len(entries)})") return True except Exception as e: logger.error(f"Error creating tab for {source_name}: {e}") return False def get_short_source_name(self, full_name): """Получаем короткое имя для вкладки (Таба) из полного имени источника""" if full_name is None: return "UNKNOWN" short_names = { 'proton_lg': 'PROTON_LG', 'proton_ge': 'PROTON_GE', 'wine_kron4ek': 'WINE_KRON4EK', 'wine_ge': 'WINE_GE', 'proton_cachyos': 'PROTON_CACHYOS', 'winepak': 'WINEPAK', 'proton_sarek': 'PROTON_SAREK', 'wine_staging': 'WINE_STAGING', 'wine_valve': 'WINE_VALVE', 'proton_valve': 'PROTON_VALVE', 'proton_em': 'PROTON_EM' } return short_names.get(full_name.lower(), full_name.upper()) def add_asset_row_from_json(self, table, row_index, entry, source_name): """Добавляем строку для определенной позиции из JSON""" # Извлекаем имя файла из URL url = entry.get('url', '') filename = entry.get('name', '') if url: parsed_url = urllib.parse.urlparse(url) url_filename = os.path.basename(parsed_url.path) if url_filename: filename = url_filename # Извлекаем версию для уникального ID version_from_name = self.extract_version_from_name(filename) # Проверяем, установлен ли уже этот ассет uppercase_filename = filename.upper() # Преобразование имени WINE в верхний регистр is_installed = self.is_asset_installed(uppercase_filename, source_name) checkbox_widget = QWidget() checkbox_layout = QHBoxLayout(checkbox_widget) checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) checkbox_layout.setContentsMargins(0, 0, 0, 0) checkbox = QCheckBox() # Создаем структуру для позиции (элемента) asset_data = { 'name': filename, # имя с расширением 'browser_download_url': url, } if is_installed: # If asset is already installed, disable the checkbox checkbox.setEnabled(False) checkbox.setChecked(False) # Ensure it's not checked else: # Only connect the signal if the asset is not installed checkbox.stateChanged.connect(lambda state, a=asset_data, v=version_from_name, s=source_name: self.on_asset_toggled_json(state, a, v, s)) checkbox_layout.addWidget(checkbox) table.setCellWidget(row_index, 0, checkbox_widget) # Remove .tar.xz and .tar.gz extensions completely display_name = filename if filename.lower().endswith(('.tar.xz', '.tar.gz')): display_name = filename[:-7] asset_name_item = QTableWidgetItem(display_name) if is_installed: # Make the item disabled and add "(installed)" suffix asset_name_item.setFlags(asset_name_item.flags() & ~Qt.ItemFlag.ItemIsEnabled) # Add "(installed)" suffix to indicate it's already installed asset_name_item.setText(_('{display_name} (installed)').format(display_name=display_name)) table.setItem(row_index, 1, asset_name_item) # Собираем метаданные в данных элемента unique_id = f"{source_name}_{version_from_name}_{filename}" for col in range(table.columnCount()): item = table.item(row_index, col) if item: item.setData(Qt.ItemDataRole.UserRole, { 'asset': asset_data, 'unique_id': unique_id, 'json_entry': entry, 'source_name': source_name, 'version': version_from_name }) def extract_version_from_name(self, name): """Получаем версию из имени элемента""" if not name: return "N/A" # Убираем расширение файла basename = os.path.splitext(name)[0] basename = os.path.splitext(basename)[0] # Для двойных расширений .tar.gz # Получаем версию по паттернам if 'GE-Proton' in basename: parts = basename.split('-') if len(parts) >= 2: return '-'.join(parts[:2]) elif 'wine-' in basename.lower(): parts = basename.split('-') if len(parts) >= 2: return parts[1] elif 'proton-' in basename.lower(): parts = basename.split('-') if len(parts) >= 2: return parts[1] # Общий случай для всего return basename.split('-')[0] if '-' in basename else basename def is_asset_installed(self, asset_filename, source_name): """Check if asset is already installed in PortProton data/dist""" if not self.portproton_location: return False # Determine the directory name without extensions name_without_ext = asset_filename for ext in ['.tar.gz', '.tar.xz']: if name_without_ext.lower().endswith(ext): name_without_ext = name_without_ext[:-len(ext)] break # Check if the corresponding directory exists in data/dist dist_path = os.path.join(self.portproton_location, "data", "dist") expected_dir = os.path.join(dist_path, name_without_ext) return os.path.exists(expected_dir) def on_cell_clicked(self, row): """Обработка клика по ячейке - переключение флажка при клике по любой ячейке в строке""" tab = self.tab_widget.currentWidget() table = tab.findChild(QTableWidget) if table: checkbox_widget = table.cellWidget(row, 0) if checkbox_widget: checkbox = checkbox_widget.findChild(QCheckBox) if checkbox and checkbox.isEnabled(): checkbox.setChecked(not checkbox.isChecked()) def on_asset_toggled_json(self, state, asset, version, source_name): """Обработка выбора/отмены выбора элемента из данных JSON""" # Всегда извлекаем имя файла из URL url = asset.get('browser_download_url', '') filename = asset.get('name', '') # Исходное имя из JSON # Получаем имя файла из URL (оно всегда с расширением) if url: parsed_url = urllib.parse.urlparse(url) url_filename = os.path.basename(parsed_url.path) if url_filename: # Если удалось получить имя из URL filename = url_filename unique_id = f"{source_name}_{version}_{filename}" if state == Qt.CheckState.Checked.value: self.selected_assets[unique_id] = { 'source_name': source_name, 'version': version, 'asset': asset, 'asset_name': filename, # Используем имя файла с расширением 'download_url': asset['browser_download_url'] } else: if unique_id in self.selected_assets: del self.selected_assets[unique_id] self.update_selection_display() def update_selection_display(self): """Обновляем отображение выбора""" if self.selected_assets: selection_text = _('Selected {} assets:\n').format(len(self.selected_assets)) for i, asset_data in enumerate(self.selected_assets.values(), 1): selection_text += f"{i}. {asset_data['source_name'].upper()} - {asset_data['asset_name']}\n" self.selection_text.setPlainText(selection_text) self.download_btn.setEnabled(True) else: self.selection_text.setPlainText(_("No assets selected")) self.download_btn.setEnabled(False) def clear_selection(self): """Очищаем (сбрасываем) всё выбранное""" if self.is_downloading: QMessageBox.warning(self, _("Downloading in Progress"), _("Cannot clear selection while extraction is in progress.")) return self.selected_assets.clear() for tab_index in range(self.tab_widget.count()): tab = self.tab_widget.widget(tab_index) table = tab.findChild(QTableWidget) if table: for row in range(table.rowCount()): checkbox_widget = table.cellWidget(row, 0) if checkbox_widget: checkbox = checkbox_widget.findChild(QCheckBox) if checkbox and checkbox.isEnabled(): checkbox.setChecked(False) self.update_selection_display() def download_selected(self): """Extract all selected archives""" if not self.selected_assets: QMessageBox.warning(self, _("No Selection"), _("Please select at least one archive to download.")) return if self.is_downloading: QMessageBox.warning(self, _("Downloading in Progress"), _("Please wait for current downloading to complete.")) return downloads_dir = "proton_downloads" if not os.path.exists(downloads_dir): os.makedirs(downloads_dir) self.assets_to_download = list(self.selected_assets.values()) self.current_download_index = 0 self.is_downloading = True self.start_next_download() def start_next_download(self): """Start extraction of next archive in the list""" if self.current_download_index >= len(self.assets_to_download): # All extractions completed self.download_frame.setVisible(False) self.download_btn.setEnabled(True) self.clear_btn.setEnabled(True) self.is_downloading = False QMessageBox.information(self, _("Downloading Complete"), _("All selected archives have been downloaded!")) return asset_data = self.assets_to_download[self.current_download_index] self.download_asset(asset_data) def download_asset(self, asset_data): """Download and then extract the archive""" # DEBUG FEATURE: Check if proton_downloads folder exists in repo root and contains the file # This is a debug feature to use local files instead of downloading - remember to remove for production repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Go up from portprotonqt/ proton_downloads_path = os.path.join(repo_root, "proton_downloads") local_file_path = None if os.path.exists(proton_downloads_path) and os.path.isdir(proton_downloads_path): # Look for the asset file in proton_downloads for filename in os.listdir(proton_downloads_path): if filename == asset_data['asset_name']: local_file_path = os.path.join(proton_downloads_path, filename) logger.info(f"DEBUG: Using local file instead of downloading: {local_file_path}") break if local_file_path and os.path.exists(local_file_path): # Use local file, skip download logger.info(f"DEBUG: Skipping download, using local file: {local_file_path}") download_info = f"{asset_data['source_name'].upper()} - {asset_data['asset_name']} (DEBUG: local)" if len(download_info) > 80: download_info = download_info[:77] + "..." self.download_progress.setValue(0) self.download_frame.setVisible(True) self.download_btn.setEnabled(False) self.clear_btn.setEnabled(False) # Simulate download completion and start extraction immediately QTimer.singleShot(100, lambda: self.start_extraction_for_asset(asset_data, local_file_path)) else: # Normal download process # Create a temporary file path for download temp_dir = tempfile.mkdtemp(prefix="portproton_wine_") filename = os.path.join(temp_dir, asset_data['asset_name']) download_url = asset_data['download_url'] download_info = f"{asset_data['source_name'].upper()} - {asset_data['asset_name']}" if len(download_info) > 80: download_info = download_info[:77] + "..." self.download_progress.setValue(0) self.download_frame.setVisible(True) self.download_btn.setEnabled(False) self.clear_btn.setEnabled(False) # Create and start download thread self.current_download_thread = DownloadThread(download_url, filename) def update_download_progress(progress): self.download_progress.setValue(progress) self.download_info_label.setText(_("Downloading: {0} ({1}%)").format(asset_data['asset_name'], progress)) def download_finished(filepath, success): if success: logger.info(f"Successfully downloaded: {filepath}") # Now start extraction self.start_extraction_for_asset(asset_data, filepath) else: logger.error(f"Failed to download: {filepath}") # Clean up temp directory temp_dir = os.path.dirname(filepath) try: shutil.rmtree(temp_dir) except (OSError, FileNotFoundError): pass self.current_download_index += 1 QTimer.singleShot(100, self.start_next_download) def download_error(error_msg): logger.error(f"Download error: {error_msg}") QMessageBox.critical(self, "Download Error", f"Failed to download archive: {error_msg}") # Clean up temp directory temp_dir = os.path.dirname(filename) try: shutil.rmtree(temp_dir) except (OSError, FileNotFoundError): pass self.current_download_index += 1 QTimer.singleShot(100, self.start_next_download) self.current_download_thread.progress.connect(update_download_progress) self.current_download_thread.finished.connect(download_finished) self.current_download_thread.error.connect(download_error) self.current_download_thread.start() def start_extraction_for_asset(self, asset_data, filepath): """Start extraction for a downloaded asset""" # Update progress bar to show extraction phase self.download_info_label.setText(_("Extracting: {0}").format(asset_data['asset_name'])) # Extract archive to PortProton data/dist directory using a separate thread if self.portproton_location: try: # Extract directly to dist directory - let the archive create its own folder structure dist_path = os.path.join(self.portproton_location, "data", "dist") extract_dir = dist_path # Create and start extraction thread self.current_extraction_thread = ExtractionThread(filepath, extract_dir) # Store speed and ETA values to use in the display current_speed = 0.0 current_eta = 0 def update_extraction_progress(progress): self.download_progress.setValue(progress) # Update the info label to show current progress during extraction eta_text = _(', ETA: {}s').format(current_eta) if current_eta > 0 else "" speed_text = _(', Speed: {:.1f}MB/s').format(current_speed) if current_speed > 0 else "" self.download_info_label.setText(_("Extracting: {0}{1}{2}").format( asset_data['asset_name'], speed_text, eta_text)) def update_extraction_speed(speed): nonlocal current_speed current_speed = speed def update_extraction_eta(eta): # Use ETA to pass actual ETA information during extraction nonlocal current_eta # Now we only use ETA for actual estimated time of arrival current_eta = eta def extraction_finished(archive_path, success): if success: logger.info(f"Successfully extracted: {archive_path}") # Run the initial command after successful extraction import subprocess try: # Get the proper PortProton start command start_cmd = get_portproton_start_command() if start_cmd: result = subprocess.run(start_cmd + ["cli", "--initial"], timeout=10) if result.returncode != 0: logger.warning(f"Initial PortProton command returned non-zero exit code: {result.returncode}") else: logger.warning("Could not determine PortProton start command, skipping initial command") except subprocess.TimeoutExpired: logger.warning("Initial PortProton command timed out") except Exception as e: logger.error(f"Error running initial PortProton command: {e}") else: logger.error(f"Failed to extract: {archive_path}") QMessageBox.critical(self, _("Extraction Error"), _("Failed to extract archive: {0}").format(archive_path)) # Clean up temp directory after extraction temp_dir = os.path.dirname(filepath) try: shutil.rmtree(temp_dir) logger.debug(f"Cleaned up temporary directory: {temp_dir}") except Exception as e: logger.warning(f"Could not clean up temporary directory {temp_dir}: {e}") if self.current_extraction_thread and self.current_extraction_thread.isRunning(): logger.debug("Waiting for extraction thread to finish...") if not self.current_extraction_thread.wait(500): logger.warning("Extraction thread still running, but continuing...") self.current_download_index += 1 QTimer.singleShot(100, self.start_next_download) def extraction_error(error_msg): logger.error(f"Extraction error: {error_msg}") QMessageBox.critical(self, "Extraction Error", f"Failed to extract archive: {error_msg}") # Clean up temp directory after error temp_dir = os.path.dirname(filepath) try: shutil.rmtree(temp_dir) logger.debug(f"Cleaned up temporary directory after error: {temp_dir}") except Exception as e: logger.warning(f"Could not clean up temporary directory {temp_dir}: {e}") if self.current_extraction_thread and self.current_extraction_thread.isRunning(): logger.debug("Waiting for extraction thread to finish after error...") if not self.current_extraction_thread.wait(500): logger.warning("Extraction thread still running after error, but continuing...") self.current_download_index += 1 QTimer.singleShot(100, self.start_next_download) self.current_extraction_thread.progress.connect(update_extraction_progress) self.current_extraction_thread.speed.connect(update_extraction_speed) self.current_extraction_thread.eta.connect(update_extraction_eta) self.current_extraction_thread.finished.connect(extraction_finished) self.current_extraction_thread.error.connect(extraction_error) self.current_extraction_thread.start() except Exception as e: # Clean up temp directory in case of exception temp_dir = os.path.dirname(filepath) try: shutil.rmtree(temp_dir) logger.debug(f"Cleaned up temporary directory after exception: {temp_dir}") except Exception as cleanup_error: logger.warning(f"Could not clean up temporary directory {temp_dir}: {cleanup_error}") logger.error(f"Error starting extraction thread for {filepath}: {e}") QMessageBox.critical(self, "Extraction Error", f"Failed to start extraction: {e}") self.current_download_index += 1 QTimer.singleShot(100, self.start_next_download) else: # Clean up temp directory if portproton location is not provided temp_dir = os.path.dirname(filepath) try: shutil.rmtree(temp_dir) logger.debug(f"Cleaned up temporary directory: {temp_dir}") except Exception as e: logger.warning(f"Could not clean up temporary directory {temp_dir}: {e}") logger.warning("PortProton location not provided, skipping extraction") self.current_download_index += 1 QTimer.singleShot(100, self.start_next_download) def cancel_current_download(self): """Cancel current download or extraction""" # Stop extraction thread if running if self.current_extraction_thread and self.current_extraction_thread.isRunning(): self.current_extraction_thread.stop() if not self.current_extraction_thread.wait(1000): logger.warning("Extraction thread did not stop gracefully") # Stop download thread if running try: if (self.current_download_thread and hasattr(self.current_download_thread, 'isRunning') and self.current_download_thread.isRunning()): if hasattr(self.current_download_thread, 'stop'): self.current_download_thread.stop() if not self.current_download_thread.wait(1000): logger.warning("Download thread did not stop gracefully") except RuntimeError: # Object already deleted, which is fine logger.debug("Download thread object already deleted during cancel") # Очищаем список загрузок self.assets_to_download = [] self.current_download_index = 0 self.is_downloading = False # Сброс/перезапуск UI self.download_frame.setVisible(False) self.download_btn.setEnabled(True) self.clear_btn.setEnabled(True) QMessageBox.information(self, _("Operation Cancelled"), _("Download or extraction has been cancelled.")) def closeEvent(self, event): """Проверка, что все потоки останавливаются при закрытии приложения""" logger.debug("Closing ProtonManager dialog...") # Stop extraction thread if running if self.current_extraction_thread and self.current_extraction_thread.isRunning(): logger.debug("Stopping current extraction thread...") self.current_extraction_thread.stop() if not self.current_extraction_thread.wait(2000): logger.warning("Extraction thread did not stop gracefully during close") # Stop download thread if running try: if (self.current_download_thread and hasattr(self.current_download_thread, 'isRunning') and self.current_download_thread.isRunning()): logger.debug("Stopping current download thread...") if hasattr(self.current_download_thread, 'stop'): self.current_download_thread.stop() if not self.current_download_thread.wait(2000): logger.warning("Download thread did not stop gracefully during close") except RuntimeError: # Object already deleted, which is fine logger.debug("Download thread object already deleted during close") event.accept() def show_proton_manager(parent=None, portproton_location=None): """ Shows the Proton/WINE archive extractor dialog. Args: parent: Parent widget for the dialog portproton_location: Location of PortProton installation Returns: ProtonManager dialog instance """ dialog = ProtonManager(parent, portproton_location) dialog.exec() # Use exec() for modal dialog return dialog