forked from Boria138/PortProtonQt
1730 lines
76 KiB
Python
1730 lines
76 KiB
Python
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, QStackedWidget)
|
||
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, read_theme_from_config
|
||
from portprotonqt.logger import get_logger
|
||
from portprotonqt.theme_manager import ThemeManager
|
||
from portprotonqt.localization import _
|
||
from portprotonqt.version_utils import version_sort_key
|
||
from portprotonqt.dialogs import create_dialog_hints_widget, update_dialog_hints
|
||
from portprotonqt.preloader import Preloader
|
||
|
||
logger = get_logger(__name__)
|
||
theme_manager = ThemeManager()
|
||
|
||
|
||
class WineLoadingThread(QThread):
|
||
"""Thread for loading wine metadata in the background"""
|
||
loading_complete = Signal(object) # Emits the metadata
|
||
loading_error = Signal(str) # Emits error message
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
|
||
def run(self):
|
||
try:
|
||
json_url = "https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata.json"
|
||
|
||
# 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.loading_complete.emit(metadata)
|
||
except Exception as e:
|
||
logger.error(f"Error loading metadata: {e}")
|
||
self.loading_error.emit(str(e))
|
||
|
||
|
||
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, theme=None, input_manager=None):
|
||
super().__init__(parent)
|
||
self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
|
||
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.input_manager = input_manager # Input manager for gamepad support
|
||
self.initial_command_executed = False # Track if --initial command has been executed
|
||
self.wine_loading_thread = None # Thread for loading wine data
|
||
|
||
# Find main window
|
||
self.main_window = None
|
||
parent_widget = self.parent()
|
||
while parent_widget:
|
||
if hasattr(parent_widget, 'input_manager'):
|
||
self.main_window = parent_widget
|
||
break
|
||
parent_widget = parent_widget.parent()
|
||
|
||
self.initUI()
|
||
# Start loading wine data in the background after UI is initialized
|
||
self.start_loading_wine_data()
|
||
# The installed tab will be created after wine data is loaded
|
||
# Enable gamepad support if input manager is provided
|
||
if self.input_manager:
|
||
self.enable_proton_manager_mode()
|
||
|
||
def initUI(self):
|
||
self.setWindowTitle(_('Manage Wine versions'))
|
||
self.resize(1133, 720)
|
||
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(5, 5, 5, 5)
|
||
layout.setSpacing(5)
|
||
|
||
# Create a stacked widget to hold preloader and content
|
||
self.content_stack = QStackedWidget()
|
||
|
||
# Preloader widget
|
||
self.preloader_widget = QWidget()
|
||
preloader_layout = QVBoxLayout(self.preloader_widget)
|
||
preloader_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
# Center the preloader
|
||
preloader_container = QWidget()
|
||
preloader_container_layout = QVBoxLayout(preloader_container)
|
||
preloader_container_layout.addStretch()
|
||
preloader_hlayout = QHBoxLayout()
|
||
preloader_hlayout.addStretch()
|
||
|
||
self.preloader = Preloader()
|
||
preloader_hlayout.addWidget(self.preloader)
|
||
preloader_hlayout.addStretch()
|
||
preloader_container_layout.addLayout(preloader_hlayout)
|
||
preloader_container_layout.addStretch()
|
||
preloader_container_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
preloader_layout.addWidget(preloader_container)
|
||
|
||
# Content widget (tabs and controls)
|
||
self.content_widget = QWidget()
|
||
content_layout = QVBoxLayout(self.content_widget)
|
||
content_layout.setContentsMargins(5, 5, 5, 5)
|
||
content_layout.setSpacing(5)
|
||
|
||
# Tab widget - основной растягивающийся элемент
|
||
self.tab_widget = QTabWidget()
|
||
self.tab_widget.setUsesScrollButtons(False)
|
||
self.tab_widget.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE)
|
||
self.tab_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||
content_layout.addWidget(self.tab_widget, 1)
|
||
|
||
# Add widgets to stacked widget
|
||
self.content_stack.addWidget(self.preloader_widget) # Index 0: preloader
|
||
self.content_stack.addWidget(self.content_widget) # Index 1: content
|
||
self.content_stack.setCurrentIndex(0) # Show preloader initially
|
||
|
||
layout.addWidget(self.content_stack, 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.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE)
|
||
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)
|
||
|
||
# Create hints widget using common function
|
||
if self.input_manager and self.main_window:
|
||
self.current_theme_name = read_theme_from_config()
|
||
self.hints_widget, self.hints_labels = create_dialog_hints_widget(
|
||
self.theme, self.main_window, self.input_manager, context='proton_manager'
|
||
)
|
||
layout.addWidget(self.hints_widget)
|
||
|
||
# Кнопки управления
|
||
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)
|
||
|
||
# Connect tab change signal
|
||
self.tab_widget.currentChanged.connect(self.tab_changed)
|
||
|
||
# Connect signals for hints updates
|
||
if self.input_manager and self.main_window:
|
||
self.input_manager.button_event.connect(
|
||
lambda *args: update_dialog_hints(
|
||
self.hints_labels, self.main_window, self.input_manager,
|
||
theme_manager, self.current_theme_name
|
||
)
|
||
)
|
||
self.input_manager.dpad_moved.connect(
|
||
lambda *args: update_dialog_hints(
|
||
self.hints_labels, self.main_window, self.input_manager,
|
||
theme_manager, self.current_theme_name
|
||
)
|
||
)
|
||
# Initial update
|
||
update_dialog_hints(
|
||
self.hints_labels, self.main_window, self.input_manager,
|
||
theme_manager, self.current_theme_name
|
||
)
|
||
|
||
def start_loading_wine_data(self):
|
||
"""Start loading wine data in a background thread"""
|
||
# Create and start the loading thread
|
||
self.wine_loading_thread = WineLoadingThread()
|
||
self.wine_loading_thread.loading_complete.connect(self.on_wine_data_loaded)
|
||
self.wine_loading_thread.loading_error.connect(self.on_wine_data_load_error)
|
||
self.wine_loading_thread.start()
|
||
|
||
def on_wine_data_loaded(self, metadata):
|
||
"""Handle when wine data is loaded successfully"""
|
||
# Process the metadata in the main thread
|
||
self.process_metadata(metadata)
|
||
|
||
# Create the installed tab after other tabs are created (to be last)
|
||
# First remove the existing installed tab if it exists
|
||
for i in range(self.tab_widget.count()):
|
||
if self.tab_widget.tabText(i) == _("Installed"):
|
||
self.tab_widget.removeTab(i)
|
||
break
|
||
# Then create the installed tab (will be added as the last tab)
|
||
self.create_installed_tab()
|
||
|
||
# Hide the preloader and show the content
|
||
if hasattr(self, 'content_stack'):
|
||
self.content_stack.setCurrentIndex(1) # Show content, hide preloader
|
||
|
||
def on_wine_data_load_error(self, error_msg):
|
||
"""Handle when wine data loading fails"""
|
||
logger.error(f"Wine data loading failed: {error_msg}")
|
||
|
||
# Show error message but still allow the dialog to function
|
||
if hasattr(self, 'content_stack'):
|
||
self.content_stack.setCurrentIndex(1) # Show content even if loading failed
|
||
|
||
# Show error message to user
|
||
error_label = QLabel(_("Error loading wine data: {error}").format(error=error_msg))
|
||
error_label.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE)
|
||
error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
# Add error message to the tab widget or replace empty content
|
||
if hasattr(self, 'tab_widget'):
|
||
# Add error as a new tab or replace empty content
|
||
error_tab = QWidget()
|
||
error_layout = QVBoxLayout(error_tab)
|
||
error_layout.addWidget(error_label)
|
||
self.tab_widget.addTab(error_tab, _("Error"))
|
||
|
||
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():
|
||
# 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 - only filter CachyOS Proton"""
|
||
# Only apply CPU filtering to CachyOS Proton, show all versions for other sources
|
||
if source_name.lower() != 'proton_cachyos':
|
||
return entries
|
||
|
||
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_table_widget(self):
|
||
"""Helper method to create a standardized table widget"""
|
||
table = QTableWidget()
|
||
table.setAlternatingRowColors(True)
|
||
table.verticalHeader().setVisible(False)
|
||
table.setColumnCount(3) # Checkbox, Version Name, Size
|
||
table.setHorizontalHeaderLabels(['', _('Version Name'), _('Size')])
|
||
table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||
table.verticalHeader().setDefaultSectionSize(36)
|
||
table.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE)
|
||
table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
table.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||
table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
|
||
table.cellClicked.connect(self.on_cell_clicked)
|
||
table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||
header = table.horizontalHeader()
|
||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||
|
||
return table
|
||
|
||
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(5, 5, 5, 5)
|
||
layout.setSpacing(5)
|
||
|
||
table = self.create_table_widget()
|
||
|
||
# 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))
|
||
|
||
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', '')
|
||
size_human = entry.get('size_human', _('Unknown')) # Get size from JSON, default to 'Unknown'
|
||
|
||
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)
|
||
|
||
# Add size information to the third column
|
||
size_item = QTableWidgetItem(size_human)
|
||
size_item.setFlags(size_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
table.setItem(row_index, 2, size_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 create_installed_tab(self):
|
||
"""Create the 'Installed' tab showing installed wine versions with removal option"""
|
||
if not self.portproton_location:
|
||
return
|
||
|
||
dist_path = os.path.join(self.portproton_location, "data", "dist")
|
||
if not os.path.exists(dist_path):
|
||
os.makedirs(dist_path, exist_ok=True)
|
||
|
||
installed_versions = [d for d in os.listdir(dist_path) if os.path.isdir(os.path.join(dist_path, d))]
|
||
|
||
if not installed_versions:
|
||
# Create empty tab with message
|
||
tab = QWidget()
|
||
layout = QVBoxLayout(tab)
|
||
|
||
label = QLabel(_("No Wine/Proton versions installed"))
|
||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
label.setStyleSheet("font-size: 16px; padding: 50px;")
|
||
layout.addWidget(label)
|
||
|
||
self.tab_widget.addTab(tab, _("Installed"))
|
||
return
|
||
|
||
# Create tab with table for installed versions
|
||
tab = QWidget()
|
||
tab.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||
|
||
layout = QVBoxLayout(tab)
|
||
layout.setContentsMargins(5, 5, 5, 5)
|
||
layout.setSpacing(5)
|
||
|
||
table = self.create_table_widget()
|
||
|
||
# Sort installed versions
|
||
installed_versions.sort(key=version_sort_key)
|
||
table.setRowCount(len(installed_versions))
|
||
|
||
for row_index, version_name in enumerate(installed_versions):
|
||
self.add_installed_row(table, row_index, version_name)
|
||
|
||
layout.addWidget(table, 1)
|
||
|
||
|
||
|
||
self.tab_widget.addTab(tab, _("Installed"))
|
||
|
||
def add_installed_row(self, table, row_index, version_name):
|
||
"""Add a row for an installed version with delete option"""
|
||
checkbox_widget = QWidget()
|
||
checkbox_layout = QHBoxLayout(checkbox_widget)
|
||
checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
checkbox_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
checkbox = QCheckBox()
|
||
checkbox_widget.setToolTip(_("Select to remove this version"))
|
||
checkbox.stateChanged.connect(lambda state: self.on_installed_version_toggled(state))
|
||
checkbox_layout.addWidget(checkbox)
|
||
|
||
table.setCellWidget(row_index, 0, checkbox_widget)
|
||
|
||
# Add version name
|
||
version_item = QTableWidgetItem(version_name)
|
||
version_item.setFlags(version_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
table.setItem(row_index, 1, version_item)
|
||
|
||
# Calculate and add size
|
||
if self.portproton_location:
|
||
dist_path = os.path.join(self.portproton_location, "data", "dist")
|
||
version_path = os.path.join(dist_path, version_name)
|
||
size_str = self.get_directory_size(version_path)
|
||
else:
|
||
size_str = _("Unknown")
|
||
version_path = "" # Provide a default value when portproton_location is None
|
||
size_item = QTableWidgetItem(size_str)
|
||
size_item.setFlags(size_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
table.setItem(row_index, 2, size_item)
|
||
|
||
# Store version name in user data for later use
|
||
for col in range(table.columnCount()):
|
||
item = table.item(row_index, col)
|
||
if item:
|
||
item.setData(Qt.ItemDataRole.UserRole, {
|
||
'version_name': version_name,
|
||
'version_path': version_path
|
||
})
|
||
|
||
def on_installed_version_toggled(self, state):
|
||
"""Handle checkbox state changes in the installed tab"""
|
||
self.update_selection_display()
|
||
|
||
def get_directory_size(self, path):
|
||
"""Calculate directory size and return human-readable string"""
|
||
try:
|
||
total_size = 0
|
||
for dirpath, _dirnames, filenames in os.walk(path):
|
||
for filename in filenames:
|
||
filepath = os.path.join(dirpath, filename)
|
||
if os.path.exists(filepath):
|
||
total_size += os.path.getsize(filepath)
|
||
|
||
# Convert to human readable format (binary units)
|
||
if total_size == 0:
|
||
return "0 B"
|
||
elif total_size < 1024:
|
||
return f"{total_size}.0 B"
|
||
elif total_size < 1024 * 1024:
|
||
return f"{int(total_size / 1024)}.{int((total_size / 1024 * 10) % 10)} KiB"
|
||
elif total_size < 1024 * 1024 * 1024:
|
||
return f"{int(total_size / (1024 * 1024))}.{int((total_size / (1024 * 1024) * 10) % 10)} MiB"
|
||
elif total_size < 1024 * 1024 * 1024 * 1024:
|
||
return f"{int(total_size / (1024 * 1024 * 1024))}.{int((total_size / (1024 * 1024 * 1024) * 10) % 10)} GiB"
|
||
else:
|
||
return f"{int(total_size / (1024 * 1024 * 1024 * 1024))}.{int((total_size / (1024 * 1024 * 1024 * 1024) * 10) % 10)} TiB"
|
||
except Exception:
|
||
return _("Unknown")
|
||
|
||
def convert_size_to_bytes(self, size_str):
|
||
"""Convert human-readable size string to bytes"""
|
||
if not size_str or size_str == _("Unknown"):
|
||
return 0
|
||
|
||
# Remove any extra text and extract the number and unit
|
||
size_str = size_str.strip()
|
||
|
||
# Handle different units
|
||
if size_str.endswith("TiB"):
|
||
num = float(size_str[:-3].strip())
|
||
return int(num * 1024 * 1024 * 1024 * 1024)
|
||
elif size_str.endswith("GiB"):
|
||
num = float(size_str[:-3].strip())
|
||
return int(num * 1024 * 1024 * 1024)
|
||
elif size_str.endswith("MiB"):
|
||
num = float(size_str[:-3].strip())
|
||
return int(num * 1024 * 1024)
|
||
elif size_str.endswith("KiB"):
|
||
num = float(size_str[:-3].strip())
|
||
return int(num * 1024)
|
||
elif size_str.endswith("B"):
|
||
num = float(size_str[:-1].strip())
|
||
return int(num)
|
||
else:
|
||
# If format is unknown, return 0
|
||
return 0
|
||
|
||
def format_bytes(self, bytes_value):
|
||
"""Format bytes to human-readable string"""
|
||
if bytes_value == 0:
|
||
return "0 B"
|
||
elif bytes_value < 1024:
|
||
return f"{bytes_value} B"
|
||
elif bytes_value < 1024 * 1024:
|
||
kb_value = bytes_value / 1024
|
||
return f"{kb_value:.1f} KiB"
|
||
elif bytes_value < 1024 * 1024 * 1024:
|
||
mb_value = bytes_value / (1024 * 1024)
|
||
return f"{mb_value:.1f} MiB"
|
||
elif bytes_value < 1024 * 1024 * 1024 * 1024:
|
||
gb_value = bytes_value / (1024 * 1024 * 1024)
|
||
return f"{gb_value:.1f} GiB"
|
||
else:
|
||
tb_value = bytes_value / (1024 * 1024 * 1024 * 1024)
|
||
return f"{tb_value:.1f} TiB"
|
||
|
||
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())
|
||
# Update selection display after clicking
|
||
self.update_selection_display()
|
||
|
||
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):
|
||
"""Обновляем отображение выбора"""
|
||
current_tab_index = self.tab_widget.currentIndex()
|
||
current_tab_text = self.tab_widget.tabText(current_tab_index)
|
||
|
||
if current_tab_text == _("Installed"):
|
||
# Handle installed tab - count selected checkboxes
|
||
current_tab = self.tab_widget.currentWidget()
|
||
table = current_tab.findChild(QTableWidget)
|
||
if table:
|
||
selected_count = 0
|
||
total_size = 0
|
||
|
||
for row in range(table.rowCount()):
|
||
checkbox_widget = table.cellWidget(row, 0)
|
||
if checkbox_widget:
|
||
checkbox = checkbox_widget.findChild(QCheckBox)
|
||
if checkbox and checkbox.isChecked():
|
||
selected_count += 1
|
||
|
||
# Get the size for the selected item
|
||
size_item = table.item(row, 2) # Size column
|
||
if size_item:
|
||
size_text = size_item.text()
|
||
size_bytes = self.convert_size_to_bytes(size_text)
|
||
if size_bytes:
|
||
total_size += size_bytes
|
||
|
||
if selected_count > 0:
|
||
selection_text = _('Selected {} assets:\n').format(selected_count)
|
||
|
||
# Add the specific version names that are selected
|
||
current_tab = self.tab_widget.currentWidget()
|
||
table = current_tab.findChild(QTableWidget)
|
||
if table:
|
||
# Create a counter for numbering the selected items
|
||
item_number = 1
|
||
for row in range(table.rowCount()):
|
||
checkbox_widget = table.cellWidget(row, 0)
|
||
if checkbox_widget:
|
||
checkbox = checkbox_widget.findChild(QCheckBox)
|
||
if checkbox and checkbox.isChecked():
|
||
version_item = table.item(row, 1) # Version name column
|
||
if version_item:
|
||
version_name = version_item.text()
|
||
selection_text += f"{item_number}. {version_name}\n"
|
||
item_number += 1
|
||
|
||
# Add total size to the selection text
|
||
total_size_text = self.format_bytes(total_size)
|
||
selection_text += _("\nTotal size to delete: {}\n").format(total_size_text)
|
||
|
||
self.download_btn.setText(_('Delete Selected'))
|
||
self.download_btn.setEnabled(True)
|
||
else:
|
||
selection_text = _("No assets selected")
|
||
self.download_btn.setText(_('Delete Selected'))
|
||
self.download_btn.setEnabled(False)
|
||
|
||
self.selection_text.setPlainText(selection_text)
|
||
else:
|
||
self.selection_text.setPlainText(_("No assets selected"))
|
||
self.download_btn.setText(_('Delete Selected'))
|
||
self.download_btn.setEnabled(False)
|
||
else:
|
||
# Handle other tabs - use selected_assets dictionary
|
||
if self.selected_assets:
|
||
selection_text = _('Selected {} assets:\n').format(len(self.selected_assets))
|
||
|
||
total_size = 0
|
||
|
||
for i, asset_data in enumerate(self.selected_assets.values(), 1):
|
||
selection_text += f"{i}. {asset_data['asset_name']}\n"
|
||
|
||
# Get size from JSON entry if available
|
||
# We need to search through all tabs to find the matching entry
|
||
for tab_index in range(self.tab_widget.count()):
|
||
tab = self.tab_widget.widget(tab_index)
|
||
table = tab.findChild(QTableWidget)
|
||
if table and self.tab_widget.tabText(tab_index) != _("Installed"):
|
||
# Search for the item in the table to get its size
|
||
for row in range(table.rowCount()):
|
||
table_item = table.item(row, 1) # Name column
|
||
if table_item:
|
||
# Extract just the name without extensions for comparison
|
||
table_item_name = table_item.text()
|
||
# Remove common extensions for comparison
|
||
for ext in ['.tar.xz', '.tar.gz', '.zip']:
|
||
if table_item_name.lower().endswith(ext):
|
||
table_item_name = table_item_name[:-len(ext)]
|
||
break
|
||
|
||
asset_name_for_comparison = asset_data['asset_name']
|
||
for ext in ['.tar.xz', '.tar.gz', '.zip']:
|
||
if asset_name_for_comparison.lower().endswith(ext):
|
||
asset_name_for_comparison = asset_name_for_comparison[:-len(ext)]
|
||
break
|
||
|
||
if table_item_name == asset_name_for_comparison:
|
||
user_data = table_item.data(Qt.ItemDataRole.UserRole)
|
||
if user_data and 'json_entry' in user_data:
|
||
json_entry = user_data['json_entry']
|
||
size_text = json_entry.get('size_human', 'Unknown')
|
||
size_bytes = self.convert_size_to_bytes(size_text)
|
||
if size_bytes:
|
||
total_size += size_bytes
|
||
break
|
||
|
||
# Add total size to the selection text
|
||
total_size_text = self.format_bytes(total_size)
|
||
selection_text += _("\nTotal size to download: {}\n").format(total_size_text)
|
||
|
||
self.selection_text.setPlainText(selection_text)
|
||
self.download_btn.setText(_('Download Selected'))
|
||
self.download_btn.setEnabled(True)
|
||
else:
|
||
self.selection_text.setPlainText(_("No assets selected"))
|
||
self.download_btn.setText(_('Download Selected'))
|
||
self.download_btn.setEnabled(False)
|
||
|
||
def tab_changed(self, index):
|
||
"""Handle tab change to update button text appropriately"""
|
||
current_tab_text = self.tab_widget.tabText(index)
|
||
if current_tab_text == _("Installed"):
|
||
# Count selected items in installed tab
|
||
current_tab = self.tab_widget.widget(index)
|
||
table = current_tab.findChild(QTableWidget)
|
||
if table:
|
||
selected_count = 0
|
||
for row in range(table.rowCount()):
|
||
checkbox_widget = table.cellWidget(row, 0)
|
||
if checkbox_widget:
|
||
checkbox = checkbox_widget.findChild(QCheckBox)
|
||
if checkbox and checkbox.isChecked():
|
||
selected_count += 1
|
||
|
||
if selected_count > 0:
|
||
self.download_btn.setText(_('Delete Selected'))
|
||
self.download_btn.setEnabled(True)
|
||
else:
|
||
self.download_btn.setText(_('Delete Selected'))
|
||
self.download_btn.setEnabled(False)
|
||
else:
|
||
# For other tabs, use the selected_assets dictionary
|
||
if self.selected_assets:
|
||
self.download_btn.setText(_('Download Selected'))
|
||
self.download_btn.setEnabled(True)
|
||
else:
|
||
self.download_btn.setText(_('Download Selected'))
|
||
self.download_btn.setEnabled(False)
|
||
|
||
self.update_selection_display()
|
||
|
||
def clear_selection(self):
|
||
"""Очищаем (сбрасываем) всё выбранное"""
|
||
if self.is_downloading:
|
||
QMessageBox.warning(self, _("Downloading in Progress"), _("Cannot clear selection while extraction is in progress."))
|
||
return
|
||
|
||
# Clear selected assets for download tabs
|
||
self.selected_assets.clear()
|
||
|
||
# Clear checkboxes in all tabs
|
||
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:
|
||
checkbox.setChecked(False)
|
||
|
||
self.update_selection_display()
|
||
|
||
def download_selected(self):
|
||
"""Handle both downloading new versions and removing installed versions"""
|
||
# Check if we're on the Installed tab
|
||
current_tab_index = self.tab_widget.currentIndex()
|
||
current_tab_text = self.tab_widget.tabText(current_tab_index)
|
||
|
||
if current_tab_text == _("Installed"):
|
||
# Handle removal of selected installed versions
|
||
self.remove_selected_installed_versions()
|
||
else:
|
||
# Handle downloading of selected versions (existing functionality)
|
||
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 remove_selected_installed_versions(self):
|
||
"""Delete selected installed wine/proton versions"""
|
||
# Get the current tab (Installed tab)
|
||
current_tab = self.tab_widget.currentWidget()
|
||
table = current_tab.findChild(QTableWidget)
|
||
if not table:
|
||
return
|
||
|
||
# Find all selected versions to remove
|
||
versions_to_remove = []
|
||
for row in range(table.rowCount()):
|
||
checkbox_widget = table.cellWidget(row, 0)
|
||
if checkbox_widget:
|
||
checkbox = checkbox_widget.findChild(QCheckBox)
|
||
if checkbox and checkbox.isChecked():
|
||
item = table.item(row, 1) # Version name column
|
||
if item:
|
||
user_data = item.data(Qt.ItemDataRole.UserRole)
|
||
if user_data:
|
||
versions_to_remove.append(user_data['version_path'])
|
||
|
||
if not versions_to_remove:
|
||
# Temporarily disable proton manager mode to allow gamepad input in QMessageBox
|
||
if self.input_manager:
|
||
self.disable_proton_manager_mode()
|
||
try:
|
||
QMessageBox.warning(self, _("No Selection"), _("Please select at least one version to delete."))
|
||
finally:
|
||
# Re-enable proton manager mode after QMessageBox closes
|
||
if self.input_manager:
|
||
self.enable_proton_manager_mode()
|
||
return
|
||
|
||
# Temporarily disable proton manager mode to allow gamepad input in QMessageBox
|
||
if self.input_manager:
|
||
self.disable_proton_manager_mode()
|
||
try:
|
||
# Confirm deletion
|
||
reply = QMessageBox.question(
|
||
self,
|
||
_("Confirm Deletion"),
|
||
_("Are you sure you want to delete {} selected version(s)?\n\nThis action cannot be undone.").format(len(versions_to_remove)),
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No
|
||
)
|
||
finally:
|
||
# Re-enable proton manager mode after QMessageBox closes
|
||
if self.input_manager:
|
||
self.enable_proton_manager_mode()
|
||
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
return
|
||
|
||
# Remove the selected versions
|
||
removed_count = 0
|
||
for version_path in versions_to_remove:
|
||
try:
|
||
if os.path.exists(version_path):
|
||
import shutil
|
||
shutil.rmtree(version_path)
|
||
removed_count += 1
|
||
except Exception as e:
|
||
logger.error(f"Error removing version at {version_path}: {e}")
|
||
QMessageBox.warning(self, _("Error"), _("Failed to remove version at {}: {}").format(version_path, str(e)))
|
||
|
||
if removed_count > 0:
|
||
QMessageBox.information(self, _("Success"), _("Successfully removed {} version(s).").format(removed_count))
|
||
# Refresh the installed tab to show updated list
|
||
self.refresh_installed_tab()
|
||
|
||
def refresh_installed_tab(self):
|
||
"""Refresh the installed tab to show current installed versions"""
|
||
# Find the installed tab index
|
||
installed_tab_index = -1
|
||
for i in range(self.tab_widget.count()):
|
||
if self.tab_widget.tabText(i) == _("Installed"):
|
||
installed_tab_index = i
|
||
break
|
||
|
||
if installed_tab_index != -1:
|
||
# Remove the old installed tab
|
||
self.tab_widget.removeTab(installed_tab_index)
|
||
# Create a new one
|
||
self.create_installed_tab()
|
||
|
||
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
|
||
|
||
# Run the initial command after all assets have been processed
|
||
import subprocess
|
||
try:
|
||
# Get the proper PortProton start command
|
||
start_cmd = get_portproton_start_command()
|
||
if start_cmd and not self.initial_command_executed:
|
||
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.info("Initial PortProton command executed successfully after all assets processed")
|
||
self.initial_command_executed = True # Mark that command has been executed
|
||
elif self.initial_command_executed:
|
||
logger.debug("Initial PortProton command already executed, skipping")
|
||
except subprocess.TimeoutExpired:
|
||
logger.warning("Initial PortProton command timed out")
|
||
except Exception as e:
|
||
logger.error(f"Error running initial PortProton command: {e}")
|
||
|
||
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_frame.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE)
|
||
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_frame.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE)
|
||
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}")
|
||
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 has_active_processes(self):
|
||
"""Check if there are active download or extraction processes"""
|
||
extraction_active = (self.current_extraction_thread and
|
||
self.current_extraction_thread.isRunning())
|
||
download_active = (self.current_download_thread and
|
||
hasattr(self.current_download_thread, 'isRunning') and
|
||
self.current_download_thread.isRunning())
|
||
return extraction_active or download_active
|
||
|
||
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
|
||
# Сбрасываем флаг выполнения команды --initial, так как процесс отменен
|
||
self.initial_command_executed = 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 enable_proton_manager_mode(self):
|
||
"""Enable gamepad mode for ProtonManager"""
|
||
if self.input_manager:
|
||
self.input_manager.enable_proton_manager_mode(self)
|
||
|
||
def disable_proton_manager_mode(self):
|
||
"""Disable gamepad mode for ProtonManager"""
|
||
if self.input_manager:
|
||
self.input_manager.disable_proton_manager_mode()
|
||
|
||
def closeEvent(self, event):
|
||
"""Проверка, что все потоки останавливаются при закрытии приложения"""
|
||
logger.debug("Closing ProtonManager dialog...")
|
||
|
||
# Disable gamepad mode before closing
|
||
if self.input_manager:
|
||
self.disable_proton_manager_mode()
|
||
|
||
# Check if there are active processes and cancel them
|
||
if self.has_active_processes():
|
||
logger.debug("Active processes detected, cancelling before close...")
|
||
self.cancel_current_download()
|
||
else:
|
||
# 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")
|
||
|
||
# If we're closing without active processes but haven't completed all downloads,
|
||
# reset the initial command flag so it can run if the dialog is opened again
|
||
if self.is_downloading and self.current_download_index < len(self.assets_to_download):
|
||
self.initial_command_executed = False
|
||
|
||
event.accept()
|
||
|
||
def reject(self):
|
||
"""Override reject to properly cancel active processes before closing"""
|
||
# Disable gamepad mode before rejecting
|
||
if self.input_manager:
|
||
self.disable_proton_manager_mode()
|
||
|
||
if self.has_active_processes():
|
||
logger.debug("Active processes detected, cancelling before reject...")
|
||
self.cancel_current_download()
|
||
else:
|
||
# If we're rejecting without active processes but haven't completed all downloads,
|
||
# reset the initial command flag so it can run if the dialog is opened again
|
||
if self.is_downloading and self.current_download_index < len(self.assets_to_download):
|
||
self.initial_command_executed = False
|
||
super().reject()
|
||
|
||
|
||
|
||
def show_proton_manager(parent=None, portproton_location=None, input_manager=None):
|
||
"""
|
||
Shows the Proton/WINE archive extractor dialog asynchronously.
|
||
|
||
Args:
|
||
parent: Parent widget for the dialog
|
||
portproton_location: Location of PortProton installation
|
||
input_manager: Input manager for gamepad support
|
||
|
||
Returns:
|
||
ProtonManager dialog instance
|
||
"""
|
||
dialog = ProtonManager(parent, portproton_location, input_manager=input_manager)
|
||
dialog.show() # Show the dialog without blocking
|
||
return dialog
|