From a9e9f4e4e3390ca1ba3f2f5e8b1554934104b45b Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Fri, 26 Dec 2025 00:23:07 +0500 Subject: [PATCH] get_other_wine: added initial Signed-off-by: Boris Yumankulov --- portprotonqt/get_wine_module.py | 890 ++++++++++++++++++++++++++++++++ portprotonqt/main_window.py | 6 + 2 files changed, 896 insertions(+) create mode 100644 portprotonqt/get_wine_module.py diff --git a/portprotonqt/get_wine_module.py b/portprotonqt/get_wine_module.py new file mode 100644 index 0000000..4d0962c --- /dev/null +++ b/portprotonqt/get_wine_module.py @@ -0,0 +1,890 @@ +import os +import requests +import orjson +import tarfile +import shutil +from PySide6.QtWidgets import (QDialog, QTabWidget, QTableWidget, + QTableWidgetItem, QVBoxLayout, QWidget, QCheckBox, + QPushButton, QHeaderView, QMessageBox, + QLabel, QTextEdit, QHBoxLayout, QProgressBar, + QFrame, QSizePolicy) +from PySide6.QtCore import Qt, QThread, Signal, QMutex, QWaitCondition, QTimer +import urllib.parse +from portprotonqt.config_utils import read_proxy_config +from portprotonqt.logger import get_logger +from portprotonqt.localization import _ + +logger = get_logger(__name__) + +def get_requests_session(): + """Create a requests session with proxy support""" + session = requests.Session() + proxy = read_proxy_config() or {} + if proxy: + session.proxies.update(proxy) + session.verify = True + return session + +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: + session = get_requests_session() + 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) + finished = Signal(str, bool) # filename, success + error = Signal(str) + + def __init__(self, archive_path, extract_dir): + super().__init__() + self.archive_path = archive_path + self.extract_dir = extract_dir + self._is_running = True + self._mutex = QMutex() + + def run(self): + try: + # Update progress to show extraction is starting + self.progress.emit(0) + + # Create dist directory if it doesn't exist + os.makedirs(os.path.dirname(self.extract_dir), exist_ok=True) + + # Remove existing directory if it exists + if os.path.exists(self.extract_dir): + shutil.rmtree(self.extract_dir) + + # Extract the archive (only .tar.gz and .tar.xz are supported according to metadata) + if self.archive_path.lower().endswith(('.tar.gz', '.tar.xz')): + with tarfile.open(self.archive_path, 'r:*') as tar_ref: + # Get total number of members for progress tracking (only once) + members = tar_ref.getmembers() + total_members = len(members) + extracted_count = 0 + + # Extract to a temporary directory first, then move contents to final destination + # to avoid nested directory structures + import tempfile + with tempfile.TemporaryDirectory() as temp_dir: + # Extract with progress tracking + last_progress = -1 # Track last emitted progress to avoid too frequent updates + for member in members: + self._mutex.lock() + if not self._is_running: + self._mutex.unlock() + return + self._mutex.unlock() + + tar_ref.extract(member, temp_dir) + extracted_count += 1 + + # Update progress + if total_members > 0: + progress = int((extracted_count / total_members) * 100) + # Only emit progress if it changed significantly (at least 1%) or at start/end + if progress != last_progress: + self.progress.emit(progress) + last_progress = progress + else: + # If total_members is 0, emit a default progress to show activity + if extracted_count == 1: + self.progress.emit(50) # Show partial progress to indicate activity + # Process events to ensure UI updates + QThread.yieldCurrentThread() # Allow other threads to run + + # Find the actual content directory (often the archive creates a subdirectory) + extracted_dirs = os.listdir(temp_dir) + if len(extracted_dirs) == 1: + # If there's only one directory, move its contents directly to extract_dir + content_dir = os.path.join(temp_dir, extracted_dirs[0]) + if os.path.isdir(content_dir): + # Move all contents from content_dir to extract_dir + items = os.listdir(content_dir) + total_items = len(items) + moved_items = 0 + last_progress = -1 # Track last emitted progress to avoid too frequent updates + + for item in items: + self._mutex.lock() + if not self._is_running: + self._mutex.unlock() + return + self._mutex.unlock() + + source = os.path.join(content_dir, item) + destination = os.path.join(self.extract_dir, item) + + if os.path.isdir(source): + shutil.copytree(source, destination) + else: + shutil.copy2(source, destination) + + moved_items += 1 + # Update progress during file copying (50% to 100% of extraction) + progress = min(100, 50 + int((moved_items / total_items) * 50)) + # Only emit progress if it changed significantly (at least 1%) + if progress != last_progress: + self.progress.emit(progress) + last_progress = progress + # Process events to ensure UI updates + QThread.yieldCurrentThread() # Allow other threads to run + else: + # If it's just files, move them directly + items = os.listdir(temp_dir) + total_items = len(items) + moved_items = 0 + last_progress = -1 # Track last emitted progress to avoid too frequent updates + + for item in items: + self._mutex.lock() + if not self._is_running: + self._mutex.unlock() + return + self._mutex.unlock() + + source = os.path.join(temp_dir, item) + destination = os.path.join(self.extract_dir, item) + + if os.path.isdir(source): + shutil.copytree(source, destination) + else: + shutil.copy2(source, destination) + + moved_items += 1 + # Update progress during file copying (50% to 100% of extraction) + progress = min(100, 50 + int((moved_items / total_items) * 50)) + # Only emit progress if it changed significantly (at least 1%) + if progress != last_progress: + self.progress.emit(progress) + last_progress = progress + # Process events to ensure UI updates + QThread.yieldCurrentThread() # Allow other threads to run + else: + # If multiple top-level items, extract directly to target + # This is a simpler case where we extract directly to the target + tar_ref.extractall(self.extract_dir) + # Set progress to 100% when extraction is complete + self.progress.emit(100) + QThread.yieldCurrentThread() # Allow other threads to run + else: + raise ValueError(f"Unsupported archive format: {self.archive_path}. Only .tar.gz and .tar.xz are supported.") + + self._mutex.lock() + if self._is_running: + self._mutex.unlock() + self.finished.emit(self.archive_path, True) + else: + self._mutex.unlock() + + except Exception as e: + self._mutex.lock() + if self._is_running: + self._mutex.unlock() + 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 ProtonManager(QDialog): + def __init__(self, parent=None, portproton_location=None): + super().__init__(parent) + self.selected_assets = {} # {unique_id: asset_data} + self.current_download_thread = None + self.current_extraction_thread = None + self.is_downloading = False + 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(_('Proton | WINE Download Manager')) + self.resize(800, 600) + + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + layout.setSpacing(5) + + # Info label + self.info_label = QLabel(_("Loading Proton versions from JSON metadata...")) + self.info_label.setMaximumHeight(20) + layout.addWidget(self.info_label) + + # 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.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}") + session = get_requests_session() + 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") + + successful_tabs = self.process_metadata(metadata) + + if successful_tabs == 0: + self.info_label.setText(_("Error: Could not process any data from JSON.")) + else: + self.info_label.setText(f"Loaded {successful_tabs} Proton/WINE sources from JSON") + + except requests.exceptions.RequestException as e: + logger.error(f"Network error loading JSON: {e}") + self.info_label.setText(_("Error loading JSON: {0}").format(e)) + except orjson.JSONDecodeError as e: + logger.error(f"JSON parsing error: {e}") + self.info_label.setText(_("Error parsing JSON: {0}").format(e)) + except Exception as e: + logger.error(f"Error loading metadata: {e}") + self.info_label.setText(_("Error: {0}").format(e)) + + def process_metadata(self, metadata): + """Обработка JSON, создание Табов""" + successful_tabs = 0 + + # Собираем табы в словарь для сортировки + 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 + + tabs_dict[source_key] = 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, entries in tabs_dict.items(): + if self.create_tab_from_entries(source_key, entries): + successful_tabs += 1 + + return successful_tabs + + 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.setColumnCount(2) # Только Checkbox и Имя + table.setHorizontalHeaderLabels(['', 'Asset Name']) + + header = table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + + table.setRowCount(len(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(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(entries)} assets") + 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""" + checkbox_widget = QWidget() + checkbox_layout = QHBoxLayout(checkbox_widget) + checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + checkbox_layout.setContentsMargins(0, 0, 0, 0) + + checkbox = QCheckBox() + + # Извлекаем имя файла из 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) + + # Создаем структуру для позиции (элемента) + asset_data = { + 'name': filename, # имя с расширением + 'browser_download_url': url, + } + + # Проверяем, установлен ли уже этот ассет + is_installed = self.is_asset_installed(filename, source_name) + + 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'): + display_name = filename[:-7] # Remove '.tar.xz' + elif filename.lower().endswith('.tar.gz'): + display_name = filename[:-7] # Remove '.tar.gz' + else: + # Fallback to removing just the last extension if needed + display_name = os.path.splitext(filename)[0] + asset_name_item = QTableWidgetItem(display_name) + + # Если ассет уже установлен, делаем его недоступным для выбора + if is_installed: + checkbox.setEnabled(False) + asset_name_item.setFlags(asset_name_item.flags() & ~Qt.ItemFlag.ItemIsEnabled) + # Add "(installed)" suffix to indicate it's already installed + asset_name_item.setText(f"{display_name} (installed)") + + 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 = f"Selected {len(self.selected_assets)} assets:\n" + + 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, _("Download in Progress"), _("Cannot clear selection while downloading.")) + 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): + """Загружаем все выбранные элементы (протоны)""" + if not self.selected_assets: + QMessageBox.warning(self, _("No Selection"), _("Please select at least one asset to download.")) + return + + if self.is_downloading: + QMessageBox.warning(self, _("Download in Progress"), _("Please wait for current download 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): + """Запуск загрузки следующего элемента в списке""" + if self.current_download_index >= len(self.assets_to_download): + # Все загрузки завершены + self.download_frame.setVisible(False) + self.download_btn.setEnabled(True) + self.clear_btn.setEnabled(True) + self.is_downloading = False + QMessageBox.information(self, _("Download Complete"), _("All selected assets 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): + """Загрузка конкретного элемента (протона)""" + filename = os.path.join("proton_downloads", asset_data['asset_name']) + + download_info = f"{asset_data['source_name'].upper()} - {asset_data['asset_name']}" + if len(download_info) > 80: + download_info = download_info[:77] + "..." + self.download_info_label.setText(f"Downloading: {download_info}") + self.download_progress.setValue(0) + self.download_frame.setVisible(True) + self.download_btn.setEnabled(False) + self.clear_btn.setEnabled(False) + + if os.path.exists(filename): + reply = QMessageBox.question(self, _("File Exists"), + _("File {0} already exists. Overwrite?").format(asset_data['asset_name']), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + if reply == QMessageBox.StandardButton.No: + self.current_download_index += 1 + self.start_next_download() + return + + download_url = asset_data.get('download_url', '') + if not download_url: + QMessageBox.critical(self, _("Download Error"), _("No download URL for {0}").format(asset_data['asset_name'])) + self.current_download_index += 1 + self.start_next_download() + return + + # Проверка, что предыдущий поток завершен + if self.current_download_thread and self.current_download_thread.isRunning(): + logger.warning("Previous thread still running, waiting...") + self.current_download_thread.stop() + + self.current_download_thread = DownloadThread(download_url, filename) + + def update_progress(progress): + self.download_progress.setValue(progress) + + def download_finished(filename, success): + logger.debug(f"Download finished callback: {filename}, success: {success}") + if success: + logger.info(f"Successfully downloaded: {filename}") + + # 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: + # Determine the name for the extraction directory from the asset name + asset_name = asset_data['asset_name'] + + # Remove common archive extensions to get the directory name (only .tar.gz and .tar.xz) + name_without_ext = asset_name + for ext in ['.tar.gz', '.tar.xz']: + if name_without_ext.lower().endswith(ext): + name_without_ext = name_without_ext[:-len(ext)] + break + + # Create the destination directory in PortProton data/dist + dist_path = os.path.join(self.portproton_location, "data", "dist") + extract_dir = os.path.join(dist_path, name_without_ext) + + # Create and start extraction thread + self.current_extraction_thread = ExtractionThread(filename, extract_dir) + + def update_extraction_progress(progress): + self.download_progress.setValue(progress) + # Update the info label to show current progress percentage during extraction + self.download_info_label.setText(_("Extracting: {0} ({1}%)").format(asset_data['asset_name'], progress)) + + def extraction_finished(archive_path, success): + if success: + logger.info(f"Successfully extracted: {archive_path}") + else: + logger.error(f"Failed to extract: {archive_path}") + QMessageBox.critical(self, _("Extraction Error"), _("Failed to extract archive: {0}").format(archive_path)) + + 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}") + + 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.finished.connect(extraction_finished) + self.current_extraction_thread.error.connect(extraction_error) + self.current_extraction_thread.start() + + except Exception as e: + logger.error(f"Error starting extraction thread for {filename}: {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: + logger.warning("PortProton location not provided, skipping extraction") + self.current_download_index += 1 + QTimer.singleShot(100, self.start_next_download) + else: + QMessageBox.critical(self, _("Download Failed"), + _("Failed to download:\n{0}").format(filename)) + + def download_error(error_msg): + logger.error(f"Download error: {error_msg}") + QMessageBox.critical(self, _("Download Error"), error_msg) + + if self.current_download_thread and self.current_download_thread.isRunning(): + if not self.current_download_thread.wait(500): + logger.warning("Thread still running after error, but continuing...") + + self.current_download_index += 1 + QTimer.singleShot(100, self.start_next_download) + + self.current_download_thread.progress.connect(update_progress) + self.current_download_thread.finished.connect(download_finished) + self.current_download_thread.error.connect(download_error) + self.current_download_thread.start() + + def cancel_current_download(self): + """Отмена текущей загрузки""" + if self.current_download_thread and self.current_download_thread.isRunning(): + self.current_download_thread.stop() + if not self.current_download_thread.wait(1000): + logger.warning("Thread did not stop gracefully") + + # Очищаем список загрузок + 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, _("Download Cancelled"), _("Download has been cancelled.")) + + def closeEvent(self, event): + """Проверка, что все потоки останавливаются при закрытии приложения""" + logger.debug("Closing ProtonManager dialog...") + + # Stop download thread if running + if self.is_downloading and self.current_download_thread and self.current_download_thread.isRunning(): + logger.debug("Stopping current download thread...") + self.current_download_thread.stop() + if not self.current_download_thread.wait(2000): + logger.warning("Thread did not stop gracefully during close") + + # 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") + + event.accept() + + + +def show_proton_manager(parent=None, portproton_location=None): + """ + Shows the Proton/WINE manager 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 diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py index f8e7a29..6b50c77 100644 --- a/portprotonqt/main_window.py +++ b/portprotonqt/main_window.py @@ -37,6 +37,7 @@ from portprotonqt.downloader import Downloader from portprotonqt.tray_manager import TrayManager from portprotonqt.game_library_manager import GameLibraryManager from portprotonqt.virtual_keyboard import VirtualKeyboard +from portprotonqt.get_wine_module import show_proton_manager from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QDialog, QFormLayout, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout, QScrollArea, QScroller, QSlider) @@ -1724,6 +1725,7 @@ class MainWindow(QMainWindow): (_("Delete Compatibility Tool"), self.delete_compat_tool), (_("Delete Prefix"), self.delete_prefix), (_("Clear Prefix"), self.clear_prefix), + (_("Download other WINE"), self.show_proton_manager), ] for i, (text, callback) in enumerate(additional_buttons): @@ -1844,6 +1846,10 @@ class MainWindow(QMainWindow): logger.error(f"Wine tool {cli_arg} error: {error}") QMessageBox.warning(self, _("Error"), f"Failed to launch tool: {error}") + def show_proton_manager(self): + """Shows the Proton/WINE manager for downloading other WINE versions""" + show_proton_manager(self, self.portproton_location) + def clear_prefix(self): """Очищает префикс""" selected_prefix = self.prefixCombo.currentText()