4 Commits

Author SHA1 Message Date
8fd44c575b fix: expose gamesListWidget from GameLibraryManager to fix gamepad navigation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 13:21:58 +05:00
65b43c1572 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 00:04:55 +05:00
f35276abfe fix: reject candidate if normalized name equals "game"
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 00:02:06 +05:00
6fea9a9a7e chore(wine settings): rework layout
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-05 20:01:00 +05:00
5 changed files with 77 additions and 276 deletions

View File

@@ -15,6 +15,7 @@
- Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений - Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений
- Исправлено зависание при добавлении или удалении игры в Wayland - Исправлено зависание при добавлении или удалении игры в Wayland
- Исправлено зависание при поиске игр - Исправлено зависание при поиске игр
- Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity)
### Contributors ### Contributors

View File

@@ -35,6 +35,7 @@ class MainWindowProtocol(Protocol):
_last_card_width: int _last_card_width: int
current_hovered_card: GameCard | None current_hovered_card: GameCard | None
current_focused_card: GameCard | None current_focused_card: GameCard | None
gamesListWidget: QWidget | None
class GameLibraryManager: class GameLibraryManager:
def __init__(self, main_window: MainWindowProtocol, theme, context_menu_manager: ContextMenuManager | None): def __init__(self, main_window: MainWindowProtocol, theme, context_menu_manager: ContextMenuManager | None):

View File

@@ -597,6 +597,9 @@ class InputManager(QObject):
def handle_dpad_slot(self, code: int, value: int, current_time: float) -> None: def handle_dpad_slot(self, code: int, value: int, current_time: float) -> None:
if not self._gamepad_handling_enabled: if not self._gamepad_handling_enabled:
return return
if not hasattr(self._parent, 'gamesListWidget') or self._parent.gamesListWidget is None:
logger.error("gamesListWidget not available yet, skipping D-pad navigation")
return
try: try:
app = QApplication.instance() app = QApplication.instance()

View File

@@ -5,9 +5,6 @@ import signal
import subprocess import subprocess
import sys import sys
import psutil import psutil
import tarfile
import glob
import re
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.dialogs import AddGameDialog, FileExplorer from portprotonqt.dialogs import AddGameDialog, FileExplorer
@@ -41,7 +38,7 @@ from portprotonqt.game_library_manager import GameLibraryManager
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox,
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy) QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout)
from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot
from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices
from typing import cast from typing import cast
@@ -772,6 +769,7 @@ class MainWindow(QMainWindow):
def createInstalledTab(self): def createInstalledTab(self):
self.gamesLibraryWidget = self.game_library_manager.create_games_library_widget() self.gamesLibraryWidget = self.game_library_manager.create_games_library_widget()
self.stackedWidget.addWidget(self.gamesLibraryWidget) self.stackedWidget.addWidget(self.gamesLibraryWidget)
self.gamesListWidget = self.game_library_manager.gamesListWidget
self.game_library_manager.update_game_grid() self.game_library_manager.update_game_grid()
def resizeEvent(self, event): def resizeEvent(self, event):
@@ -1010,24 +1008,12 @@ class MainWindow(QMainWindow):
# Путь к дистрибутивам Wine/Proton # Путь к дистрибутивам Wine/Proton
if self.portproton_location is None: if self.portproton_location is None:
content = QLabel(_("PortProton location not set"))
content.setStyleSheet(self.theme.CONTENT_STYLE)
content.setObjectName("tabContent")
layout.addWidget(content)
layout.addStretch(1)
self.stackedWidget.addWidget(self.wineWidget)
return return
dist_path = os.path.join(self.portproton_location, "data", "dist") dist_path = os.path.join(self.portproton_location, "data", "dist")
prefixes_path = os.path.join(self.portproton_location, "data", "prefixes") prefixes_path = os.path.join(self.portproton_location, "data", "prefixes")
if not os.path.exists(dist_path): if not os.path.exists(dist_path):
content = QLabel(_("PortProton data/dist not found"))
content.setStyleSheet(self.theme.CONTENT_STYLE)
content.setObjectName("tabContent")
layout.addWidget(content)
layout.addStretch(1)
self.stackedWidget.addWidget(self.wineWidget)
return return
# Форма с настройками # Форма с настройками
@@ -1066,9 +1052,10 @@ class MainWindow(QMainWindow):
layout.addLayout(formLayout) layout.addLayout(formLayout)
# Кнопки для стандартных инструментов Wine # Кнопки для стандартных инструментов Wine в сетке 2x3
toolsLayout = QHBoxLayout() tools_grid = QGridLayout()
toolsLayout.setSpacing(10) tools_grid.setSpacing(10)
tools_grid.setContentsMargins(0, 0, 0, 0)
tools = [ tools = [
("winecfg", _("Wine Configuration")), ("winecfg", _("Wine Configuration")),
@@ -1079,264 +1066,59 @@ class MainWindow(QMainWindow):
("cmd", _("Command Prompt")), ("cmd", _("Command Prompt")),
] ]
for tool_cmd, tool_name in tools: for i, (_tool_cmd, tool_name) in enumerate(tools):
btn = AutoSizeButton(tool_name) row = i // 3
col = i % 3
btn = AutoSizeButton(tool_name, update_size=False) # Отключаем авторазмер для избежания проблем
btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus) btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
btn.clicked.connect(lambda checked, t=tool_cmd: self.run_wine_tool(t)) tools_grid.addWidget(btn, row, col)
toolsLayout.addWidget(btn)
# Растягиваем столбцы равномерно
for col in range(3):
tools_grid.setColumnStretch(col, 1)
layout.addLayout(tools_grid)
# Дополнительные инструменты в сетке 1x4 или 2x2 если нужно
additional_grid = QGridLayout()
additional_grid.setSpacing(10)
additional_grid.setContentsMargins(0, 0, 0, 0)
# Wine Uninstaller
uninstaller_btn = AutoSizeButton(_("Uninstaller"), update_size=False)
uninstaller_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
uninstaller_btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
additional_grid.addWidget(uninstaller_btn, 0, 0)
# Winetricks
winetricks_btn = AutoSizeButton(_("Winetricks"), update_size=False)
winetricks_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
winetricks_btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
additional_grid.addWidget(winetricks_btn, 0, 1)
# Create Backup
create_backup_btn = AutoSizeButton(_("Create Prefix Backup"), update_size=False)
create_backup_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
create_backup_btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
additional_grid.addWidget(create_backup_btn, 0, 2)
# Load Backup
load_backup_btn = AutoSizeButton(_("Load Prefix Backup"), update_size=False)
load_backup_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
load_backup_btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
additional_grid.addWidget(load_backup_btn, 0, 3)
# Растягиваем столбцы равномерно
for col in range(4):
additional_grid.setColumnStretch(col, 1)
layout.addLayout(additional_grid)
layout.addLayout(toolsLayout)
layout.addStretch(1) layout.addStretch(1)
self.stackedWidget.addWidget(self.wineWidget) self.stackedWidget.addWidget(self.wineWidget)
def prepare_wine(self, version):
"""Подготавливает окружение Wine/Proton для выбранной версии."""
if not version:
return
if self.portproton_location is None:
logger.warning("PortProton location not set")
return
dist_path = os.path.join(self.portproton_location, "data", "dist")
winendir = os.path.join(dist_path, version)
if not os.path.exists(winendir):
logger.warning(f"Wine directory not found: {winendir}")
return
files_dir = os.path.join(winendir, "files")
dist_dir = os.path.join(winendir, "dist")
proton_tar = os.path.join(winendir, "proton_dist.tar")
if os.path.isdir(files_dir) and not os.path.isdir(dist_dir):
for item in os.listdir(winendir):
if item not in ["files", "version"]:
item_path = os.path.join(winendir, item)
if os.path.isdir(item_path):
shutil.rmtree(item_path)
else:
os.remove(item_path)
if os.path.exists(files_dir):
for item in os.listdir(files_dir):
shutil.move(os.path.join(files_dir, item), winendir)
os.rmdir(files_dir)
elif not os.path.isdir(files_dir) and os.path.isdir(dist_dir):
for item in os.listdir(winendir):
if item not in ["dist", "version"]:
item_path = os.path.join(winendir, item)
if os.path.isdir(item_path):
shutil.rmtree(item_path)
else:
os.remove(item_path)
if os.path.exists(dist_dir):
for item in os.listdir(dist_dir):
shutil.move(os.path.join(dist_dir, item), winendir)
os.rmdir(dist_dir)
elif os.path.isfile(proton_tar):
with tarfile.open(proton_tar) as tar:
tar.extractall(winendir)
os.remove(proton_tar)
for item in os.listdir(winendir):
if item not in ["bin", "lib", "lib64", "share", "version"]:
item_path = os.path.join(winendir, item)
if os.path.isdir(item_path):
shutil.rmtree(item_path)
else:
os.remove(item_path)
if os.path.exists(winendir):
# Создать файл version
version_file = os.path.join(winendir, "version")
if not os.path.exists(version_file):
with open(version_file, "w") as f:
f.write(version)
# Симлинк lib64/wine
lib_wine = os.path.join(winendir, "lib", "wine", "x86_64-unix")
lib64_wine = os.path.join(winendir, "lib64", "wine")
if not os.path.lexists(lib64_wine) and os.path.exists(lib_wine):
os.makedirs(os.path.join(winendir, "lib64"), exist_ok=True)
self.safe_symlink(os.path.join(winendir, "lib", "wine"), lib64_wine)
# Обработка mono и gecko
tmp_path = os.path.join(self.portproton_location, "tmp")
os.makedirs(tmp_path, exist_ok=True)
for component in ["mono", "gecko"]:
share_wine_comp = os.path.join(winendir, "share", "wine", component)
tmp_comp = os.path.join(tmp_path, component)
if os.path.lexists(share_wine_comp) and os.path.islink(share_wine_comp):
logger.info(f"{share_wine_comp} is symlink. OK.")
elif os.path.isdir(share_wine_comp):
self.safe_copytree(share_wine_comp, tmp_comp)
self.safe_rmtree(share_wine_comp)
self.safe_symlink(tmp_comp, share_wine_comp)
logger.info(f"Copied {component} to tmp and created symlink. OK.")
else:
self.safe_rmtree(share_wine_comp)
if os.path.exists(tmp_comp):
self.safe_symlink(tmp_comp, share_wine_comp)
logger.warning(f"{share_wine_comp} is broken symlink. Repaired.")
# Модификация wine.inf
wine_inf = os.path.join(winendir, "share", "wine", "wine.inf")
if os.path.exists(wine_inf):
with open(wine_inf) as f:
lines = f.readlines()
nvidia_uuid = 'Global,"{41FCC608-8496-4DEF-B43E-7D9BD675A6FF}",0x10001,0x00000001'
has_nvidia = any(nvidia_uuid in line for line in lines)
if not has_nvidia:
lines.append('HKLM,Software\\NVIDIA Corporation\\Global,"{41FCC608-8496-4DEF-B43E-7D9BD675A6FF}",0x10001,0x00000001\n')
lines.append('HKLM,System\\ControlSet001\\Services\\nvlddmkm,"{41FCC608-8496-4DEF-B43E-7D9BD675A6FF}",0x10001,0x00000001\n')
new_lines = []
for line in lines:
if 'Steam.exe' in line or r'\\Valve\\Steam' in line or 'winemenubuilder' in line:
continue
new_lines.append(line)
lines = new_lines
with open(wine_inf, "w") as f:
f.writelines(lines)
# Удаление steam и winemenubuilder файлов
for libdir in ["lib", "lib64"]:
lib_path = os.path.join(winendir, libdir)
if os.path.exists(lib_path):
# *steam*
for pattern in [
os.path.join(lib_path, "*steam*"),
os.path.join(lib_path, "wine", "*", "*steam*"),
os.path.join(lib_path, "wine", "*-windows", "winemenubuilder.exe")
]:
for file_path in glob.glob(pattern, recursive=True):
try:
os.remove(file_path)
except Exception:
pass
def safe_symlink(self, src, dst):
"""Создает симлинк, удаляя dst если существует."""
if os.path.exists(dst):
if os.path.islink(dst):
os.remove(dst)
else:
shutil.rmtree(dst)
os.symlink(src, dst)
def safe_copytree(self, src, dst):
"""Копирует директорию, удаляя dst если существует."""
if os.path.exists(dst):
shutil.rmtree(dst)
shutil.copytree(src, dst)
def safe_rmtree(self, path):
"""Удаляет директорию если существует."""
if os.path.exists(path):
shutil.rmtree(path)
def clean_wine_dist_dirs(self):
"""Normalizes Wine dist directory names to uppercase with underscores."""
if self.portproton_location is None:
return
dist_path = os.path.join(self.portproton_location, "data", "dist")
if not os.path.exists(dist_path):
return
for entry in os.scandir(dist_path):
if entry.is_dir():
dist_dir = entry.name
dist_dir_stripped = re.sub(r'\s+', ' ', dist_dir.strip())
dist_dir_new = dist_dir_stripped.replace(' ', '_').upper()
if dist_dir_new != dist_dir:
new_path = os.path.join(dist_path, dist_dir_new)
if not os.path.exists(new_path):
try:
os.rename(entry.path, new_path)
logger.info(f"Renamed {dist_dir} to {dist_dir_new}")
except Exception as e:
logger.error(f"Failed to rename {dist_dir} to {dist_dir_new}: {e}")
def run_wine_tool(self, tool_cmd: str):
"""Запускает инструмент Wine с выбранной версией и префиксом."""
version = self.wineCombo.currentText()
prefix = self.prefixCombo.currentText()
if not version:
QMessageBox.warning(self, _("Error"), _("Please select a Wine/Proton version"))
return
if not prefix:
QMessageBox.warning(self, _("Error"), _("Please select a prefix"))
return
if self.portproton_location is None:
QMessageBox.warning(self, _("Error"), _("PortProton location not set"))
return
# Clean and normalize dist directories
self.clean_wine_dist_dirs()
# Repopulate wineCombo with normalized names
dist_path = os.path.join(self.portproton_location, "data", "dist")
self.wine_versions = sorted([d for d in os.listdir(dist_path) if os.path.isdir(os.path.join(dist_path, d))])
self.wineCombo.clear()
self.wineCombo.addItems(self.wine_versions)
# Try to select the normalized original version
version_normalized = version.strip().replace(' ', '_').upper()
index = self.wineCombo.findText(version_normalized)
if index != -1:
self.wineCombo.setCurrentIndex(index)
version = version_normalized
elif self.wine_versions:
self.wineCombo.setCurrentIndex(0)
version = self.wine_versions[0]
else:
QMessageBox.warning(self, _("Error"), _("No Wine versions found after cleaning"))
return
# Prepare Wine for the (possibly updated) version
self.prepare_wine(version)
prefixes_path = os.path.join(self.portproton_location, "data", "prefixes")
winendir = os.path.join(dist_path, version)
wine_bin = os.path.join(winendir, "bin", "wine")
wineserver_bin = os.path.join(winendir, "bin", "wineserver")
if not os.path.exists(wine_bin):
QMessageBox.warning(self, _("Error"), _("Wine binary not found: {}").format(wine_bin))
return
prefix_dir = os.path.join(prefixes_path, prefix)
if not os.path.exists(prefix_dir):
QMessageBox.warning(self, _("Error"), _("Prefix not found: {}").format(prefix_dir))
return
env = os.environ.copy()
env['WINEPREFIX'] = prefix_dir
env['WINEDIR'] = winendir
env['WINE'] = wine_bin
env['WINELOADER'] = wine_bin
env['WINESERVER'] = wineserver_bin
env['WINEDEBUG'] = '-all'
env['WINEDLLOVERRIDES'] = "steam_api,steam_api64,steamclient,steamclient64=n;dotnetfx35.exe,dotnetfx35setup.exe=b;winemenubuilder.exe=;mscoree="
try:
if tool_cmd == "cmd":
# Open Command Prompt in a separate terminal
term_cmd = ["x-terminal-emulator", "-e", wine_bin, tool_cmd]
subprocess.Popen(term_cmd, env=env, start_new_session=True)
else:
cmd = [wine_bin, tool_cmd]
subprocess.Popen(cmd, env=env, start_new_session=True)
self.statusBar().showMessage(_("Launched {} for prefix {}").format(tool_cmd, prefix), 3000)
except Exception as e:
logger.error(f"Failed to launch {tool_cmd}: {e}")
QMessageBox.warning(self, _("Error"), _("Failed to launch {}: {}").format(tool_cmd, str(e)))
def createPortProtonTab(self): def createPortProtonTab(self):
"""Вкладка 'PortProton Settings'.""" """Вкладка 'PortProton Settings'."""
self.portProtonWidget = QWidget() self.portProtonWidget = QWidget()

View File

@@ -211,14 +211,28 @@ def normalize_name(s):
def is_valid_candidate(candidate): def is_valid_candidate(candidate):
""" """
Checks if a candidate contains forbidden substrings: Determines whether a given candidate string is valid for use as a game name.
- win32
- win64 The function performs the following checks:
- gamelauncher 1. Normalizes the candidate using `normalize_name()`.
Additionally checks the string without spaces. 2. Rejects the candidate if the normalized name is exactly "game"
Returns True if the candidate is valid, otherwise False. (to avoid overly generic names).
3. Removes spaces and checks for forbidden substrings:
- "win32"
- "win64"
- "gamelauncher"
These are checked in the space-free version of the string.
4. Returns True only if none of the forbidden conditions are met.
Args:
candidate (str): The candidate string to validate.
Returns:
bool: True if the candidate is valid, False otherwise.
""" """
normalized_candidate = normalize_name(candidate) normalized_candidate = normalize_name(candidate)
if normalized_candidate == "game":
return False
normalized_no_space = normalized_candidate.replace(" ", "") normalized_no_space = normalized_candidate.replace(" ", "")
forbidden = ["win32", "win64", "gamelauncher"] forbidden = ["win32", "win64", "gamelauncher"]
for token in forbidden: for token in forbidden: