From 240f685ecef5ec42c6324003724713b06f12cacc Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Tue, 7 Oct 2025 12:06:35 +0500 Subject: [PATCH] feat(wine settings): make winetricks work Signed-off-by: Boris Yumankulov --- portprotonqt/dialogs.py | 384 ++++++++++++++++++++++++- portprotonqt/main_window.py | 22 +- portprotonqt/themes/standart/styles.py | 90 ++++++ 3 files changed, 491 insertions(+), 5 deletions(-) diff --git a/portprotonqt/dialogs.py b/portprotonqt/dialogs.py index 7af8b7a..58614aa 100644 --- a/portprotonqt/dialogs.py +++ b/portprotonqt/dialogs.py @@ -2,11 +2,13 @@ import os import tempfile import re from typing import cast, TYPE_CHECKING -from PySide6.QtGui import QPixmap, QIcon +from PySide6.QtGui import QPixmap, QIcon, QTextCursor from PySide6.QtWidgets import ( - QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller + QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller, + QTabWidget, QTableWidget, QHeaderView, QMessageBox, QTableWidgetItem, QTextEdit ) -from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot + +from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot, QProcess, QProcessEnvironment from icoextract import IconExtractor, IconExtractorError from PIL import Image from portprotonqt.config_utils import get_portproton_location, read_favorite_folders, read_theme_from_config @@ -977,3 +979,379 @@ Icon={icon_path} """ return desktop_entry, desktop_path + +class WinetricksDialog(QDialog): + """Dialog for managing Winetricks components in a prefix.""" + + def __init__(self, parent=None, theme=None, prefix_path: str | None = None, wine_use: str | None = None): + super().__init__(parent) + self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config()) + self.prefix_path: str | None = prefix_path + self.wine_use: str | None = wine_use + self.portproton_path = get_portproton_location() + if self.portproton_path is None: + logger.error("PortProton location not found") + return + self.tmp_path = os.path.join(self.portproton_path, "data", "tmp") + os.makedirs(self.tmp_path, exist_ok=True) + self.winetricks_path = os.path.join(self.tmp_path, "winetricks") + if self.prefix_path is None: + logger.error("Prefix path not provided") + return + self.log_path = os.path.join(self.prefix_path, "winetricks.log") + os.makedirs(os.path.dirname(self.log_path), exist_ok=True) + if not os.path.exists(self.log_path): + open(self.log_path, 'a').close() + + self.downloader = Downloader(max_workers=4) + self.apply_process: QProcess | None = None + + self.setWindowTitle(_("Prefix Manager")) + self.setModal(True) + self.resize(700, 700) + self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE) + + self.update_winetricks() + self.setup_ui() + self.load_lists() + + def update_winetricks(self): + """Update the winetricks script.""" + if not self.downloader.has_internet(): + logger.warning("No internet connection, skipping winetricks update") + return + + url = "https://raw.githubusercontent.com/Winetricks/winetricks/master/src/winetricks" + temp_path = os.path.join(self.tmp_path, "winetricks_temp") + + try: + self.downloader.download(url, temp_path) + with open(temp_path) as f: + ext_content = f.read() + ext_ver_match = re.search(r'WINETRICKS_VERSION\s*=\s*[\'"]?([^\'"\s]+)', ext_content) + ext_ver = ext_ver_match.group(1) if ext_ver_match else None + logger.info(f"External winetricks version: {ext_ver}") + except Exception as e: + logger.error(f"Failed to get external version: {e}") + ext_ver = None + if os.path.exists(temp_path): + os.remove(temp_path) + return + + int_ver = None + if os.path.exists(self.winetricks_path): + try: + with open(self.winetricks_path) as f: + int_content = f.read() + int_ver_match = re.search(r'WINETRICKS_VERSION\s*=\s*[\'"]?([^\'"\s]+)', int_content) + int_ver = int_ver_match.group(1) if int_ver_match else None + logger.info(f"Internal winetricks version: {int_ver}") + except Exception as e: + logger.error(f"Failed to read internal winetricks version: {e}") + + update_needed = not os.path.exists(self.winetricks_path) or (int_ver != ext_ver and ext_ver) + + if update_needed: + try: + self.downloader.download(url, self.winetricks_path) + os.chmod(self.winetricks_path, 0o755) + logger.info(f"Winetricks updated to version {ext_ver}") + self.apply_modifications(self.winetricks_path) + except Exception as e: + logger.error(f"Failed to update winetricks: {e}") + elif os.path.exists(self.winetricks_path): + self.apply_modifications(self.winetricks_path) + + if os.path.exists(temp_path): + os.remove(temp_path) + + def apply_modifications(self, file_path): + """Apply custom modifications to the winetricks script.""" + if not os.path.exists(file_path): + return + try: + with open(file_path) as f: + content = f.read() + + # Apply sed-like replacements + content = re.sub(r'w_metadata vcrun2015 dlls \\', r'w_metadata !dont_use_2015! dlls \\', content) + content = re.sub(r'w_metadata vcrun2017 dlls \\', r'w_metadata !dont_use_2017! dlls \\', content) + content = re.sub(r'w_metadata vcrun2019 dlls \\', r'w_metadata !dont_use_2019! dlls \\', content) + content = re.sub(r'w_set_winver win2k3', r'w_set_winver win7', content) + + with open(file_path, 'w') as f: + f.write(content) + logger.info("Winetricks modifications applied") + except Exception as e: + logger.error(f"Error applying modifications to winetricks: {e}") + + def setup_ui(self): + """Set up the user interface with tabs and tables.""" + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.setSpacing(10) + + # Log output + self.log_output = QTextEdit() + self.log_output.setReadOnly(True) + self.log_output.setStyleSheet(self.theme.WINETRICKS_LOG_STYLE) + main_layout.addWidget(self.log_output) + + # Tab widget + self.tab_widget = QTabWidget() + self.tab_widget.setStyleSheet(self.theme.WINETRICKS_TAB_STYLE) + + table_base_style = self.theme.WINETRICKS_TABBLE_STYLE + + # DLLs tab + self.dll_table = QTableWidget() + self.dll_table.setColumnCount(3) + self.dll_table.setHorizontalHeaderLabels([_("Set"), _("Libraries"), _("Information")]) + self.dll_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) + self.dll_table.horizontalHeader().resizeSection(0, 50) + self.dll_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + self.dll_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + self.dll_table.setStyleSheet(table_base_style) + self.tab_widget.addTab(self.dll_table, _("DLLs")) + + # Fonts tab + self.fonts_table = QTableWidget() + self.fonts_table.setColumnCount(3) + self.fonts_table.setHorizontalHeaderLabels([_("Set"), _("Fonts"), _("Information")]) + self.fonts_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) + self.fonts_table.horizontalHeader().resizeSection(0, 50) + self.fonts_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + self.fonts_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + self.fonts_table.setStyleSheet(table_base_style) + self.tab_widget.addTab(self.fonts_table, _("Fonts")) + + # Settings tab + self.settings_table = QTableWidget() + self.settings_table.setColumnCount(3) + self.settings_table.setHorizontalHeaderLabels([_("Set"), _("Settings"), _("Information")]) + self.settings_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) + self.settings_table.horizontalHeader().resizeSection(0, 50) + self.settings_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + self.settings_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + self.settings_table.setStyleSheet(table_base_style) + self.tab_widget.addTab(self.settings_table, _("Settings")) + + main_layout.addWidget(self.tab_widget) + + # Buttons + button_layout = QHBoxLayout() + button_layout.setSpacing(10) + self.cancel_button = AutoSizeButton(_("Cancel"), icon=theme_manager.get_icon("cancel")) + self.force_button = AutoSizeButton(_("Force Install"), theme_manager.get_icon("apply")) + self.install_button = AutoSizeButton(_("Install"), icon=theme_manager.get_icon("apply")) + self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) + self.force_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) + self.install_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) + button_layout.addWidget(self.cancel_button) + button_layout.addWidget(self.force_button) + button_layout.addWidget(self.install_button) + main_layout.addLayout(button_layout) + + self.cancel_button.clicked.connect(self.reject) + self.force_button.clicked.connect(lambda: self.install_selected(force=True)) + self.install_button.clicked.connect(lambda: self.install_selected(force=False)) + + def load_lists(self): + """Load and populate the lists for DLLs, Fonts, and Settings""" + if not os.path.exists(self.winetricks_path): + QMessageBox.warning(self, _("Error"), _("Winetricks not found. Please try again.")) + self.reject() + return + + assert self.prefix_path is not None + env = QProcessEnvironment.systemEnvironment() + env.insert("WINEPREFIX", self.prefix_path) + if self.wine_use is not None: + env.insert("WINE", self.wine_use) + + cwd = os.path.dirname(self.winetricks_path) + + # DLLs + self._start_list_process("dlls", self.dll_table, self.get_dll_exclusions(), env, cwd) + + # Fonts + self._start_list_process("fonts", self.fonts_table, self.get_fonts_exclusions(), env, cwd) + + # Settings + self._start_list_process("settings", self.settings_table, self.get_settings_exclusions(), env, cwd) + + + def _start_list_process(self, category, table, exclusion_pattern, env, cwd): + """Запускает QProcess для списка.""" + process = QProcess(self) + process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels) + process.setProcessEnvironment(env) + process.finished.connect(lambda exit_code, exit_status: self._on_list_finished(category, table, exclusion_pattern, process, exit_code, exit_status)) + process.start(self.winetricks_path, [category, "list"]) + + def _on_list_finished(self, category, table, exclusion_pattern, process: QProcess | None, exit_code, exit_status): + """Обработчик завершения списка.""" + if process is None: + logger.error(f"Process is None for {category}") + return + output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore') + if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit: + self.populate_table(table, output, exclusion_pattern, self.log_path) + else: + error_output = bytes(process.readAllStandardError().data()).decode('utf-8', 'ignore') + logger.error(f"Failed to list {category}: {error_output}") + + def get_dll_exclusions(self): + """Get regex pattern for DLL exclusions.""" + return r'(d3d|directx9|dont_use|dxvk|vkd3d|galliumnine|faudio1|Foundation)' + + def get_fonts_exclusions(self): + """Get regex pattern for Fonts exclusions.""" + return r'dont_use' + + def get_settings_exclusions(self): + """Get regex pattern for Settings exclusions.""" + return r'(vista|alldlls|autostart_|bad|good|win|videomemory|vd=|isolate_home)' + + def populate_table(self, table, output, exclusion_pattern, log_path): + """Populate the table with items from output, checking installation status.""" + table.setRowCount(0) + table.verticalHeader().setVisible(False) + lines = output.strip().split('\n') + installed = set() + if os.path.exists(log_path): + with open(log_path) as f: + for line in f: + installed.add(line.strip()) + + # regex-парсинг (имя - первое слово, остальное - описание) + line_re = re.compile(r"^\s*(?:\[(.)]\s+)?([^\s]+)\s*(.*)") + + for line in lines: + line = line.strip() + if not line or re.search(exclusion_pattern, line, re.I): + continue + + line = line.split('(', 1)[0].strip() + + match = line_re.match(line) + if not match: + continue + + _status, name, info = match.groups() + # Очищаем info от мусора + info = re.sub(r'\[.*?\]', '', info).strip() # Удаляем [скачивания] и т.п. + + # To match bash desc extraction: after name, substr(2) to trim leading space + if info.startswith(' '): + info = info[1:].lstrip() + + # Фильтр служебных строк + if '/' in name or '\\' in name or name.lower() in ('executing', 'using', 'warning:') or name.endswith(':'): + continue + + checked = Qt.CheckState.Checked if name in installed else Qt.CheckState.Unchecked + + row = table.rowCount() + table.insertRow(row) + + # Checkbox + checkbox = QTableWidgetItem() + checkbox.setCheckState(checked) + table.setItem(row, 0, checkbox) + + # Name + name_item = QTableWidgetItem(name) + table.setItem(row, 1, name_item) + + # Info + info_item = QTableWidgetItem(info) + table.setItem(row, 2, info_item) + + def install_selected(self, force=False): + """Install selected components.""" + selected = [] + for table in [self.dll_table, self.fonts_table, self.settings_table]: + for row in range(table.rowCount()): + checkbox = table.item(row, 0) + if checkbox is not None and checkbox.checkState() == Qt.CheckState.Checked: + name_item = table.item(row, 1) + if name_item is not None: + name = name_item.text() + if name and name not in selected: + selected.append(name) + + if not selected: + QMessageBox.information(self, _("Info"), _("No components selected.")) + return + + # Load installed + installed = set() + if os.path.exists(self.log_path): + with open(self.log_path) as f: + for line in f: + installed.add(line.strip()) + + # Filter to new selected + new_selected = [name for name in selected if name not in installed] + + if not new_selected: + QMessageBox.information(self, _("Info"), _("No new components selected.")) + return + + self.install_button.setEnabled(False) + self.force_button.setEnabled(False) + self.cancel_button.setEnabled(False) + + self._start_install_process(new_selected, force) + + def _start_install_process(self, selected, force): + """Запускает QProcess для установки.""" + assert self.prefix_path is not None + env = QProcessEnvironment.systemEnvironment() + env.insert("WINEPREFIX", self.prefix_path) + if self.wine_use is not None: + env.insert("WINE", self.wine_use) + + self.apply_process = QProcess(self) + self.apply_process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels) + self.apply_process.setProcessEnvironment(env) + self.apply_process.readyReadStandardOutput.connect(self._on_ready_read) + self.apply_process.finished.connect(lambda exit_code, exit_status: self._on_install_finished(exit_code, exit_status, selected)) + args = ["--unattended"] + (["--force"] if force else []) + selected + self.apply_process.start(self.winetricks_path, args) + + def _on_ready_read(self): + """Handle ready read for install process.""" + if self.apply_process is None: + return + data = self.apply_process.readAllStandardOutput().data() + message = bytes(data).decode('utf-8', 'ignore').strip() + self._log(message) + + def _on_install_finished(self, exit_code, exit_status, selected): + """Обработчик завершения установки.""" + error_message = "" + if self.apply_process is not None: + error_data = self.apply_process.readAllStandardError().data() + error_message = bytes(error_data).decode('utf-8', 'ignore') + if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit: + logger.error(f"Winetricks install failed: {error_message}") + QMessageBox.warning(self, _("Error"), _("Installation failed. Check logs.")) + else: + with open(self.log_path, 'a') as f: + for name in selected: + f.write(f"{name}\n") + logger.info("Winetricks installation completed successfully.") + QMessageBox.information(self, _("Success"), _("Components installed successfully.")) + self.load_lists() + + # Разблокировка + self.install_button.setEnabled(True) + self.force_button.setEnabled(True) + self.cancel_button.setEnabled(True) + + def _log(self, message): + """Добавляет в лог.""" + self.log_output.append(message) + self.log_output.moveCursor(QTextCursor.MoveOperation.End) diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py index 3ca5728..2d8ac84 100644 --- a/portprotonqt/main_window.py +++ b/portprotonqt/main_window.py @@ -7,7 +7,7 @@ import sys import psutil from portprotonqt.logger import get_logger -from portprotonqt.dialogs import AddGameDialog, FileExplorer +from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog from portprotonqt.game_card import GameCard from portprotonqt.animations import DetailPageAnimations from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel @@ -1079,7 +1079,7 @@ class MainWindow(QMainWindow): additional_grid.setSpacing(6) additional_buttons = [ - ("Winetricks", None), + ("Winetricks", self.open_winetricks), (_("Create Prefix Backup"), self.create_prefix_backup), (_("Load Prefix Backup"), self.load_prefix_backup), (_("Delete Compatibility Tool"), self.delete_compat_tool), @@ -1226,6 +1226,24 @@ class MainWindow(QMainWindow): except Exception as e: QMessageBox.warning(self, _("Error"), _("Failed to delete compatibility tool: {}").format(str(e))) + def open_winetricks(self): + """Open the Winetricks dialog for the selected prefix and wine.""" + selected_prefix = self.prefixCombo.currentText() + if not selected_prefix: + return + + selected_wine = self.wineCombo.currentText() + if not selected_wine: + return + + assert self.portproton_location is not None + prefix_path = os.path.join(self.portproton_location, "data", "prefixes", selected_prefix) + wine_path = os.path.join(self.portproton_location, "data", "dist", selected_wine, "bin", "wine") + + # Open Winetricks dialog + dialog = WinetricksDialog(self, self.theme, prefix_path, wine_path) + dialog.exec() + def createPortProtonTab(self): """Вкладка 'PortProton Settings'.""" self.portProtonWidget = QWidget() diff --git a/portprotonqt/themes/standart/styles.py b/portprotonqt/themes/standart/styles.py index 76694f7..f8526c1 100644 --- a/portprotonqt/themes/standart/styles.py +++ b/portprotonqt/themes/standart/styles.py @@ -916,6 +916,96 @@ SETTINGS_CHECKBOX_STYLE = f""" }} """ +WINETRICKS_TAB_STYLE = f""" +QTabWidget::pane {{ + border: 1px solid {color_d}; + background: {color_b}; + border-radius: {border_radius_a}; +}} +QTabBar::tab {{ + background: {color_c}; + color: {color_f}; + padding: 8px 16px; + border-top-left-radius: {border_radius_a}; + border-top-right-radius: {border_radius_a}; + margin-right: 2px; +}} +QTabBar::tab:selected {{ + background: {color_a}; + color: {color_f}; +}} +QTabBar::tab:hover {{ + background: {color_e}; +}} +""" + +WINETRICKS_TABBLE_STYLE = f""" +QTableWidget {{ + background: {color_c}; + color: {color_f}; + gridline-color: {color_d}; + alternate-background-color: {color_d}; + border: {border_a}; + border-radius: {border_radius_a}; + font-family: '{font_family}'; + font-size: {font_size_a}; +}} +QHeaderView::section {{ + background: {color_d}; + color: {color_f}; + padding: 5px; + border: {border_a}; + font-weight: bold; +}} +QTableWidget::item {{ + padding: 8px; + border-bottom: 1px solid {color_d}; +}} +QTableWidget::item:selected {{ + background: {color_a}; + color: {color_f}; +}} +QTableWidget::item:hover {{ + background: {color_e}; +}} +QTableWidget::indicator {{ + width: 24px; + height: 24px; + border: {border_b} {color_a}; + border-radius: {border_radius_a}; + background: rgba(255, 255, 255, 0.1); +}} +QTableWidget::indicator:unchecked {{ + background: rgba(255, 255, 255, 0.1); + image: none; +}} +QTableWidget::indicator:checked {{ + background: {color_a}; + image: url({theme_manager.get_icon("check", current_theme_name, as_path=True)}); + border: {border_b} {color_f}; +}} +QTableWidget::indicator:hover {{ + background: rgba(255, 255, 255, 0.2); + border: {border_b} {color_a}; +}} +QTableWidget::indicator:focus {{ + border: {border_c} {color_a}; +}} +{SCROLL_AREA_STYLE} +""" + +WINETRICKS_LOG_STYLE = f""" +QTextEdit {{ + background: {color_c}; + border: {border_a}; + border-radius: {border_radius_a}; + color: {color_f}; + font-family: '{font_family}'; + font-size: {font_size_a}; + padding: 5px; +}} +""" + FILE_EXPLORER_STYLE = f""" QListView {{ font-size: {font_size_a};