Compare commits
	
		
			34 Commits
		
	
	
		
			fdd5a0a3d5
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 0231073b19 | |||
| dec24429f5 | |||
| 4a758f3b3c | |||
| 0853dd1579 | |||
| bbb87c0455 | |||
| b32a71a125 | |||
|  | bddf9f850a | ||
|  | a9c3cfa167 | ||
| 7675bc4cdc | |||
| ffa203f019 | |||
| 3eed25ecee | |||
| 3736bb279e | |||
|  | b59ee5ae8e | ||
| 33176590fd | |||
| 8046065929 | |||
|  | fbad5add6c | ||
| 438e9737ea | |||
| 2d39a4c740 | |||
| 567203b0b0 | |||
| 502cbc5030 | |||
| 9b61215152 | |||
| 10d3fe8ab4 | |||
| a568ad9ef8 | |||
| f074843fc8 | |||
| 4ab078b93e | |||
| 7df6ad3b80 | |||
| 464ad0fe9c | |||
| cde92885d4 | |||
| 120c7b319c | |||
| 596aed0077 | |||
| 6fc6cb1e02 | |||
| 186e28a19b | |||
| 28e4d1e77c | |||
| fff1f888c4 | 
| @@ -94,7 +94,7 @@ jobs: | ||||
|     name: Build Arch Package | ||||
|     runs-on: ubuntu-22.04 | ||||
|     container: | ||||
|       image: archlinux:base-devel@sha256:06ab929f935145dd65994a89dd06651669ea28d43c812f3e24de990978511821 | ||||
|       image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731 | ||||
|       volumes: | ||||
|         - /usr:/usr-host | ||||
|         - /opt:/opt-host | ||||
|   | ||||
| @@ -8,7 +8,7 @@ on: | ||||
|  | ||||
| env: | ||||
|   # Common version, will be used for tagging the release | ||||
|   VERSION: 0.1.7 | ||||
|   VERSION: 0.1.8 | ||||
|   PKGDEST: "/tmp/portprotonqt" | ||||
|   PACKAGE: "portprotonqt" | ||||
|   GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} | ||||
| @@ -188,4 +188,4 @@ jobs: | ||||
|           tag_name: v${{ env.VERSION }} | ||||
|           prerelease: true | ||||
|           files: release/**/* | ||||
|           sha256sum: true | ||||
|           sha256sum: false | ||||
|   | ||||
| @@ -138,7 +138,7 @@ jobs: | ||||
|     needs: changes | ||||
|     if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch' | ||||
|     container: | ||||
|       image: archlinux:base-devel@sha256:06ab929f935145dd65994a89dd06651669ea28d43c812f3e24de990978511821 | ||||
|       image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731 | ||||
|       volumes: | ||||
|         - /usr:/usr-host | ||||
|         - /opt:/opt-host | ||||
|   | ||||
| @@ -11,12 +11,12 @@ repos: | ||||
|       - id: check-yaml | ||||
|  | ||||
|   - repo: https://github.com/astral-sh/uv-pre-commit | ||||
|     rev: 0.8.22 | ||||
|     rev: 0.9.5 | ||||
|     hooks: | ||||
|       - id: uv-lock | ||||
|  | ||||
|   - repo: https://github.com/astral-sh/ruff-pre-commit | ||||
|     rev: v0.14.0 | ||||
|     rev: v0.14.2 | ||||
|     hooks: | ||||
|       - id: ruff-check | ||||
|  | ||||
|   | ||||
							
								
								
									
										10
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						| @@ -3,16 +3,24 @@ | ||||
| Все заметные изменения в этом проекте фиксируются в этом файле. | ||||
| Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/). | ||||
|  | ||||
| ## [Unreleased] | ||||
| ## [0.1.8] - 2025-10-18 | ||||
|  | ||||
| ### Added | ||||
| - В настройки добавлен пункт для выбора типа геймпада для подсказок по управлению | ||||
| - В настройки добавлен пункт для выбора сворачивать ли приложение в трей или нет | ||||
| - К диалогу добавления игры, Winetricks, диалогу выбора файлов и виртуальной клавиатуре добавлены подсказки по управлению с геймпада | ||||
| - Во вкладку автоустановок добавлен слайдер изменения размера карточек (они со слайдером в библиотеке независимы) | ||||
|  | ||||
| ### Changed | ||||
| - При завершении автоустановки приложение больше не перезапускается | ||||
| - Выбор exe в диалоге добавления игры больше не перезаписывает введенное в поле название | ||||
| - Обновлены и дополнены скриншоты темы | ||||
|  | ||||
| ### Fixed | ||||
| - Исправлено наложение карточек при смене фильтра игр | ||||
| - Исправлена невозможность запуска приложения без подключёного геймпада | ||||
| - Исправлена невозможность установки компонентов Winetricks через геймпад | ||||
| - Ресиверы и виртуальные устройства больше не считаются за геймпад | ||||
|  | ||||
|  | ||||
| ### Contributors | ||||
|   | ||||
| @@ -36,7 +36,7 @@ AppDir: | ||||
|     id: ru.linux_gaming.PortProtonQt | ||||
|     name: PortProtonQt | ||||
|     icon: ru.linux_gaming.PortProtonQt | ||||
|     version: 0.1.7 | ||||
|     version: 0.1.8 | ||||
|     exec: usr/bin/python3 | ||||
|     exec_args: "-m portprotonqt.app $@" | ||||
|   apt: | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| pkgname=portprotonqt | ||||
| pkgver=0.1.7 | ||||
| pkgver=0.1.8 | ||||
| pkgrel=1 | ||||
| pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store" | ||||
| arch=('any') | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| %global pypi_name portprotonqt | ||||
| %global pypi_version 0.1.7 | ||||
| %global pypi_version 0.1.8 | ||||
| %global oname PortProtonQt | ||||
| %global _python_no_extras_requires 1 | ||||
|  | ||||
|   | ||||
| @@ -21,9 +21,9 @@ Current translation status: | ||||
|  | ||||
| | Locale | Progress | Translated | | ||||
| | :----- | -------: | ---------: | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 247 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 247 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 247 of 247 | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 249 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 249 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 of 249 | | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -21,9 +21,9 @@ | ||||
|  | ||||
| | Локаль | Прогресс | Переведено | | ||||
| | :----- | -------: | ---------: | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 247 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 247 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 247 из 247 | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 249 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 249 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 из 249 | | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -1,40 +1,45 @@ | ||||
| import sys | ||||
| import os | ||||
| import subprocess | ||||
| from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo | ||||
| from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo, QTimer, Qt | ||||
| from PySide6.QtWidgets import QApplication | ||||
| from PySide6.QtGui import QIcon | ||||
| from PySide6.QtNetwork import QLocalServer, QLocalSocket | ||||
|  | ||||
| from portprotonqt.main_window import MainWindow | ||||
| from portprotonqt.config_utils import save_fullscreen_config, get_portproton_location | ||||
| from portprotonqt.config_utils import ( | ||||
|     save_fullscreen_config, | ||||
|     read_fullscreen_config, | ||||
|     get_portproton_start_command | ||||
| ) | ||||
| from portprotonqt.logger import get_logger, setup_logger | ||||
| from portprotonqt.cli import parse_args | ||||
|  | ||||
| __app_id__ = "ru.linux_gaming.PortProtonQt" | ||||
| __app_name__ = "PortProtonQt" | ||||
| __app_version__ = "0.1.7" | ||||
| __app_version__ = "0.1.8" | ||||
|  | ||||
| def get_version(): | ||||
|     try: | ||||
|         commit = subprocess.check_output( | ||||
|             ['git', 'rev-parse', '--short', 'HEAD'], | ||||
|             stderr=subprocess.DEVNULL | ||||
|         ).decode('utf-8').strip() | ||||
|             ["git", "rev-parse", "--short", "HEAD"], | ||||
|             stderr=subprocess.DEVNULL, | ||||
|         ).decode("utf-8").strip() | ||||
|         return f"{__app_version__} ({commit})" | ||||
|     except (subprocess.CalledProcessError, FileNotFoundError, OSError): | ||||
|         return __app_version__ | ||||
|  | ||||
| def main(): | ||||
|     os.environ['PW_CLI'] = '1' | ||||
|     os.environ['PROCESS_LOG'] = '1' | ||||
|     os.environ['START_FROM_STEAM'] = '1' | ||||
|     os.environ["PW_CLI"] = "1" | ||||
|     os.environ["PROCESS_LOG"] = "1" | ||||
|     os.environ["START_FROM_STEAM"] = "1" | ||||
|  | ||||
|     portproton_path = get_portproton_location() | ||||
|     start_sh = get_portproton_start_command() | ||||
|  | ||||
|     if portproton_path is None: | ||||
|     if start_sh is None: | ||||
|         return | ||||
|  | ||||
|     script_path = os.path.join(portproton_path, 'data', 'scripts', 'start.sh') | ||||
|     subprocess.run([script_path, 'cli', '--initial']) | ||||
|     subprocess.run(start_sh + ["cli", "--initial"]) | ||||
|  | ||||
|     app = QApplication(sys.argv) | ||||
|     app.setWindowIcon(QIcon.fromTheme(__app_id__)) | ||||
| @@ -43,41 +48,116 @@ def main(): | ||||
|     app.setApplicationVersion(__app_version__) | ||||
|  | ||||
|     args = parse_args() | ||||
|  | ||||
|     # Setup logger with specified debug level | ||||
|     setup_logger(args.debug_level) | ||||
|  | ||||
|     # Reinitialize logger after setup to ensure it uses the new configuration | ||||
|     logger = get_logger(__name__) | ||||
|  | ||||
|     # --- Single-instance logic --- | ||||
|     server_name = __app_id__ | ||||
|     socket = QLocalSocket() | ||||
|     socket.connectToServer(server_name) | ||||
|  | ||||
|     if socket.waitForConnected(200): | ||||
|         # Второй экземпляр — передаём команду первому | ||||
|         fullscreen = args.fullscreen or read_fullscreen_config() | ||||
|         msg = b"show:fullscreen" if fullscreen else b"show" | ||||
|         socket.write(msg) | ||||
|         socket.flush() | ||||
|         socket.waitForBytesWritten(500) | ||||
|         socket.disconnectFromServer() | ||||
|         logger.info("Restored existing instance from tray") | ||||
|         return | ||||
|  | ||||
|     # Если старый сокет остался — удалить | ||||
|     QLocalServer.removeServer(server_name) | ||||
|  | ||||
|     local_server = QLocalServer() | ||||
|     if not local_server.listen(server_name): | ||||
|         logger.warning(f"Failed to start local server: {local_server.errorString()}") | ||||
|         return | ||||
|  | ||||
|     # --- Qt translations --- | ||||
|     system_locale = QLocale.system() | ||||
|     qt_translator = QTranslator() | ||||
|     translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath) | ||||
|     if qt_translator.load(system_locale, "qtbase", "_", translations_path): | ||||
|         app.installTranslator(qt_translator) | ||||
|     else: | ||||
|         logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language") | ||||
|         logger.warning( | ||||
|             f"Qt translations for {system_locale.name()} not found in {translations_path}, using English" | ||||
|         ) | ||||
|  | ||||
|     # --- Main Window --- | ||||
|     version = get_version() | ||||
|     window = MainWindow(app_name=__app_name__, version=version) | ||||
|  | ||||
|     if args.fullscreen: | ||||
|         logger.info("Launching in fullscreen mode due to --fullscreen flag") | ||||
|     # --- Handle incoming connections --- | ||||
|     def handle_new_connection(): | ||||
|         conn = local_server.nextPendingConnection() | ||||
|         if not conn: | ||||
|             return | ||||
|  | ||||
|         if conn.waitForReadyRead(1000): | ||||
|             data = conn.readAll().data() | ||||
|             msg = bytes(data).decode("utf-8", errors="ignore") | ||||
|             logger.info(f"IPC message received: {msg}") | ||||
|  | ||||
|             def restore_window(): | ||||
|                 try: | ||||
|                     if msg.startswith("show"): | ||||
|                         if hasattr(window, "restore_from_tray"): | ||||
|                             window.restore_from_tray()  # type: ignore[attr-defined] | ||||
|                         else: | ||||
|                             window.showNormal() | ||||
|                             window.raise_() | ||||
|                             window.activateWindow() | ||||
|                             window.setWindowState( | ||||
|                                 window.windowState() & ~Qt.WindowState.WindowMinimized | Qt.WindowState.WindowActive | ||||
|                             ) | ||||
|  | ||||
|                         if ":fullscreen" in msg: | ||||
|                             logger.info("Switching to fullscreen via IPC") | ||||
|                             save_fullscreen_config(True) | ||||
|                             window.showFullScreen() | ||||
|                         else: | ||||
|                             logger.info("Switching to normal window via IPC") | ||||
|                             save_fullscreen_config(False) | ||||
|                             window.showNormal() | ||||
|                 except Exception as e: | ||||
|                     logger.warning(f"Failed to restore window: {e}") | ||||
|  | ||||
|             # Выполняем в основном потоке | ||||
|             QTimer.singleShot(0, restore_window) | ||||
|  | ||||
|         conn.disconnectFromServer() | ||||
|  | ||||
|     local_server.newConnection.connect(handle_new_connection) | ||||
|  | ||||
|     # --- Initial fullscreen state --- | ||||
|     launch_fullscreen = args.fullscreen or read_fullscreen_config() | ||||
|     if launch_fullscreen: | ||||
|         logger.info( | ||||
|             f"Launching in fullscreen mode ({'--fullscreen' if args.fullscreen else 'config'})" | ||||
|         ) | ||||
|         save_fullscreen_config(True) | ||||
|         window.showFullScreen() | ||||
|     else: | ||||
|         logger.info("Launching in normal mode") | ||||
|         save_fullscreen_config(False) | ||||
|         window.showNormal() | ||||
|  | ||||
|     # --- Cleanup --- | ||||
|     def cleanup_on_exit(): | ||||
|         nonlocal window | ||||
|         app.aboutToQuit.disconnect() | ||||
|         if window: | ||||
|             window.close() | ||||
|         app.quit() | ||||
|         try: | ||||
|             local_server.close() | ||||
|             QLocalServer.removeServer(server_name) | ||||
|             if window: | ||||
|                 window.close() | ||||
|         except Exception as e: | ||||
|             logger.warning(f"Cleanup error: {e}") | ||||
|  | ||||
|     app.aboutToQuit.connect(cleanup_on_exit) | ||||
|  | ||||
|     window.show() | ||||
|  | ||||
|     sys.exit(app.exec()) | ||||
|  | ||||
| if __name__ == '__main__': | ||||
| if __name__ == "__main__": | ||||
|     main() | ||||
|   | ||||
| @@ -1,11 +1,13 @@ | ||||
| import os | ||||
| import configparser | ||||
| import shutil | ||||
| import subprocess | ||||
| from portprotonqt.logger import get_logger | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
|  | ||||
| _portproton_location = None | ||||
| _portproton_start_sh = None | ||||
|  | ||||
| # Paths to configuration files | ||||
| CONFIG_FILE = os.path.join( | ||||
| @@ -101,14 +103,14 @@ def read_file_content(file_path): | ||||
|         return f.read().strip() | ||||
|  | ||||
| def get_portproton_location(): | ||||
|     """Returns the path to the PortProton directory. | ||||
|     Checks the cached path first. If not set, reads from PORTPROTON_CONFIG_FILE. | ||||
|     If the path is invalid, uses the default directory. | ||||
|     """ | ||||
|     """Возвращает путь к PortProton каталогу (строку) или None.""" | ||||
|     global _portproton_location | ||||
|  | ||||
|     if _portproton_location is not None: | ||||
|         return _portproton_location | ||||
|  | ||||
|     location = None | ||||
|  | ||||
|     if os.path.isfile(PORTPROTON_CONFIG_FILE): | ||||
|         try: | ||||
|             location = read_file_content(PORTPROTON_CONFIG_FILE).strip() | ||||
| @@ -116,19 +118,46 @@ def get_portproton_location(): | ||||
|                 _portproton_location = location | ||||
|                 logger.info(f"PortProton path from configuration: {location}") | ||||
|                 return _portproton_location | ||||
|             logger.warning(f"Invalid PortProton path in configuration: {location}, using default path") | ||||
|             logger.warning(f"Invalid PortProton path in configuration: {location}, using defaults") | ||||
|         except (OSError, PermissionError) as e: | ||||
|             logger.warning(f"Failed to read PortProton configuration file: {e}, using default path") | ||||
|             logger.warning(f"Failed to read PortProton configuration file: {e}") | ||||
|  | ||||
|     default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton") | ||||
|     if os.path.isdir(default_dir): | ||||
|         _portproton_location = default_dir | ||||
|         logger.info(f"Using flatpak PortProton directory: {default_dir}") | ||||
|     default_flatpak_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton") | ||||
|     if os.path.isdir(default_flatpak_dir): | ||||
|         _portproton_location = default_flatpak_dir | ||||
|         logger.info(f"Using Flatpak PortProton directory: {default_flatpak_dir}") | ||||
|         return _portproton_location | ||||
|  | ||||
|     logger.warning("PortProton configuration and flatpak directory not found") | ||||
|     logger.warning("PortProton configuration and Flatpak directory not found") | ||||
|     return None | ||||
|  | ||||
| def get_portproton_start_command(): | ||||
|     """Возвращает список команд для запуска PortProton (start.sh или flatpak run).""" | ||||
|     portproton_path = get_portproton_location() | ||||
|     if not portproton_path: | ||||
|         return None | ||||
|  | ||||
|     try: | ||||
|         result = subprocess.run( | ||||
|             ["flatpak", "list"], | ||||
|             capture_output=True, | ||||
|             text=True, | ||||
|             check=False | ||||
|         ) | ||||
|         if "ru.linux_gaming.PortProton" in result.stdout: | ||||
|             logger.info("Detected Flatpak installation") | ||||
|             return ["flatpak", "run", "ru.linux_gaming.PortProton"] | ||||
|     except Exception: | ||||
|         pass | ||||
|  | ||||
|     start_sh_path = os.path.join(portproton_path, "data", "scripts", "start.sh") | ||||
|     if os.path.exists(start_sh_path): | ||||
|         return [start_sh_path] | ||||
|  | ||||
|     logger.warning("Neither flatpak nor start.sh found for PortProton") | ||||
|     return None | ||||
|  | ||||
|  | ||||
| def parse_desktop_entry(file_path): | ||||
|     """Reads and parses a .desktop file using configparser. | ||||
|     Returns None if the [Desktop Entry] section is missing. | ||||
| @@ -177,6 +206,26 @@ def save_card_size(card_width): | ||||
|     with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def read_auto_card_size(): | ||||
|     """Reads the card size (width) for Auto Install from the [Cards] section. | ||||
|     Returns 250 if the parameter is not set. | ||||
|     """ | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None or not cp.has_section("Cards") or not cp.has_option("Cards", "auto_card_width"): | ||||
|         save_auto_card_size(250) | ||||
|         return 250 | ||||
|     return cp.getint("Cards", "auto_card_width", fallback=250) | ||||
|  | ||||
| def save_auto_card_size(card_width): | ||||
|     """Saves the card size (width) for Auto Install to the [Cards] section.""" | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "Cards" not in cp: | ||||
|         cp["Cards"] = {} | ||||
|     cp["Cards"]["auto_card_width"] = str(card_width) | ||||
|     with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|         cp.write(configfile) | ||||
|  | ||||
|  | ||||
| def read_sort_method(): | ||||
|     """Reads the sort method from the [Games] section. | ||||
|     Returns 'last_launch' if the parameter is not set. | ||||
| @@ -427,3 +476,22 @@ def save_favorite_folders(folders): | ||||
|     cp["FavoritesFolders"]["folders"] = f'"{fav_str}"' | ||||
|     with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def read_minimize_to_tray(): | ||||
|     """Reads the minimize-to-tray setting from the [Display] section. | ||||
|     Returns True if the parameter is missing (default: minimize to tray). | ||||
|     """ | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "minimize_to_tray"): | ||||
|         save_minimize_to_tray(True) | ||||
|         return True | ||||
|     return cp.getboolean("Display", "minimize_to_tray", fallback=True) | ||||
|  | ||||
| def save_minimize_to_tray(minimize_to_tray): | ||||
|     """Saves the minimize-to-tray setting to the [Display] section.""" | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "Display" not in cp: | ||||
|         cp["Display"] = {} | ||||
|     cp["Display"]["minimize_to_tray"] = str(minimize_to_tray) | ||||
|     with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|         cp.write(configfile) | ||||
|   | ||||
| @@ -11,7 +11,7 @@ from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplicati | ||||
| from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt | ||||
| from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence | ||||
| from portprotonqt.localization import _ | ||||
| from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders | ||||
| from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders, get_portproton_start_command | ||||
| from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam | ||||
| from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam | ||||
| from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail | ||||
| @@ -406,16 +406,7 @@ class ContextMenuManager: | ||||
|                 ) | ||||
|                 return | ||||
|             # Construct EGS launch command | ||||
|             wrapper = "flatpak run ru.linux_gaming.PortProton" | ||||
|             start_sh_path = os.path.join(self.portproton_location, "data", "scripts", "start.sh") | ||||
|             if self.portproton_location and ".var" not in self.portproton_location: | ||||
|                 wrapper = start_sh_path | ||||
|                 if not os.path.exists(start_sh_path): | ||||
|                     self.signals.show_warning_dialog.emit( | ||||
|                         _("Error"), | ||||
|                         _("start.sh not found at {path}").format(path=start_sh_path) | ||||
|                     ) | ||||
|                     return | ||||
|             wrapper = get_portproton_start_command() | ||||
|             exec_line = f'"{self.legendary_path}" launch {game_card.appid} --no-wine --wrapper "env START_FROM_STEAM=1 {wrapper}"' | ||||
|         else: | ||||
|             exec_line = self._get_exec_line(game_card.name, game_card.exec_line) | ||||
|   | ||||
| @@ -2,12 +2,11 @@ import os | ||||
| import tempfile | ||||
| import re | ||||
| from typing import cast, TYPE_CHECKING | ||||
| from PySide6.QtGui import QPixmap, QIcon, QTextCursor | ||||
| from PySide6.QtGui import QPixmap, QIcon, QTextCursor, QColor | ||||
| from PySide6.QtWidgets import ( | ||||
|     QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller, | ||||
|     QTabWidget, QTableWidget, QHeaderView, QMessageBox, QTableWidgetItem, QTextEdit, QAbstractItemView, QStackedWidget | ||||
|     QTabWidget, QTableWidget, QHeaderView, QMessageBox, QTableWidgetItem, QTextEdit, QAbstractItemView, QStackedWidget, QComboBox | ||||
| ) | ||||
|  | ||||
| from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot, QProcess, QProcessEnvironment | ||||
| from icoextract import IconExtractor, IconExtractorError | ||||
| from PIL import Image | ||||
| @@ -1034,8 +1033,8 @@ class AddGameDialog(QDialog): | ||||
|         """Обработчик выбора файла в FileExplorer""" | ||||
|         self.exeEdit.setText(file_path) | ||||
|         self.last_exe_path = file_path  # Update last selected exe path | ||||
|         if not self.edit_mode: | ||||
|             # Автоматически заполняем имя игры, если не в режиме редактирования | ||||
|         if not self.edit_mode and not self.nameEdit.text().strip(): | ||||
|             # Автоматически заполняем имя игры, если не в режиме редактирования или если оно не введено вручную | ||||
|             game_name = os.path.splitext(os.path.basename(file_path))[0] | ||||
|             self.nameEdit.setText(game_name) | ||||
|  | ||||
| @@ -1674,3 +1673,555 @@ class WinetricksDialog(QDialog): | ||||
|         if self.input_manager: | ||||
|             self.input_manager.disable_winetricks_mode() | ||||
|         super().reject() | ||||
| class ExeSettingsDialog(QDialog): | ||||
|     def __init__(self, parent=None, theme=None, exe_path=None): | ||||
|         super().__init__(parent) | ||||
|         self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config()) | ||||
|         self.exe_path = exe_path | ||||
|         if not self.exe_path: | ||||
|             return | ||||
|         self.portproton_path = get_portproton_location() | ||||
|         if self.portproton_path is None: | ||||
|             logger.error("PortProton location not found") | ||||
|             return | ||||
|         base_path = os.path.join(self.portproton_path, "data") | ||||
|         self.start_sh = [os.path.join(base_path, "scripts", "start.sh")] | ||||
|  | ||||
|         self.current_settings = {} | ||||
|         self.value_widgets = {} | ||||
|         self.original_values = {} | ||||
|         self.advanced_widgets = {} | ||||
|         self.original_display_values = {} | ||||
|         self.available_keys = set() | ||||
|         self.blocked_keys = set() | ||||
|         self.numa_nodes = {} | ||||
|         self.is_amd = False | ||||
|         self.locale_options = [] | ||||
|         self.logical_core_options = [] | ||||
|         self.amd_vulkan_drivers = [] | ||||
|         self.branch_name = _("Unknown") | ||||
|  | ||||
|         self.setWindowTitle(_("Exe Settings")) | ||||
|         self.setModal(True) | ||||
|         self.resize(900, 600) | ||||
|         self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE) | ||||
|  | ||||
|         self.init_toggle_settings() | ||||
|         self.setup_ui() | ||||
|  | ||||
|         # Find input_manager and main_window | ||||
|         self.input_manager = None | ||||
|         self.main_window = None | ||||
|         parent = self.parent() | ||||
|         while parent: | ||||
|             if hasattr(parent, 'input_manager'): | ||||
|                 self.input_manager = cast("MainWindow", parent).input_manager | ||||
|                 self.main_window = parent | ||||
|             parent = parent.parent() | ||||
|  | ||||
|         self.current_theme_name = read_theme_from_config() | ||||
|  | ||||
|         # Load current settings (includes list-db) | ||||
|         self.load_current_settings() | ||||
|  | ||||
|     def _get_process_args(self, subcommand_args): | ||||
|         """Get the full arguments for QProcess.start, handling flatpak separator.""" | ||||
|         if self.start_sh[0] == "flatpak": | ||||
|             return self.start_sh[1:] + ["--"] + subcommand_args | ||||
|         else: | ||||
|             return self.start_sh + subcommand_args | ||||
|  | ||||
|     def init_toggle_settings(self): | ||||
|         """Initialize predefined toggle settings with descriptions.""" | ||||
|         self.toggle_settings = { | ||||
|             'PW_MANGOHUD': _("Using FPS and system load monitoring (Turns on and off by the key combination - right Shift + F12)"), | ||||
|             'PW_MANGOHUD_USER_CONF': _("Forced use of MANGOHUD system settings (GOverlay, etc.)"), | ||||
|             'PW_VKBASALT': _("Enable vkBasalt by default to improve graphics in games running on Vulkan. (The HOME hotkey disables vkbasalt)"), | ||||
|             'PW_VKBASALT_USER_CONF': _("Forced use of VKBASALT system settings (GOverlay, etc.)"), | ||||
|             'PW_DGVOODOO2': _("Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, DirectDraw 1-7, Direct3D 2-9) on all 3D API."), | ||||
|             'PW_GAMESCOPE': _("Super + F : Toggle fullscreen\nSuper + N : Toggle nearest neighbour filtering\nSuper + U : Toggle FSR upscaling\nSuper + Y : Toggle NIS upscaling\nSuper + I : Increase FSR sharpness by 1\nSuper + O : Decrease FSR sharpness by 1\nSuper + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\nSuper + G : Toggle keyboard grab\nSuper + C : Update clipboard"), | ||||
|             'PW_USE_ESYNC': _("Enable in-process synchronization primitives based on eventfd."), | ||||
|             'PW_USE_FSYNC': _("Enable futex-based in-process synchronization primitives."), | ||||
|             'PW_USE_NTSYNC': _("Enable in-process synchronization via the Linux ntsync driver."), | ||||
|             'PW_USE_RAY_TRACING': _("Enable vkd3d support - Ray Tracing"), | ||||
|             'PW_USE_NVAPI_AND_DLSS': _("Enable DLSS on supported NVIDIA graphics cards"), | ||||
|             'PW_USE_OPTISCALER': _("Enable OptiScaler (replacement upscaler / frame generator)"), | ||||
|             'PW_USE_LS_FRAME_GEN': _("Enable Lossless Scaling frame generation (experimental)"), | ||||
|             'PW_WINE_FULLSCREEN_FSR': _("FSR upscaling in fullscreen with ProtonGE below native resolution"), | ||||
|             'PW_HIDE_NVIDIA_GPU': _("Disguise all NVIDIA GPU features"), | ||||
|             'PW_VIRTUAL_DESKTOP': _("Run the application in WINE virtual desktop"), | ||||
|             'PW_USE_TERMINAL': _("Run the application in a terminal"), | ||||
|             'PW_GUI_DISABLED_CS': _("Disable startup mode and WINE version selector window"), | ||||
|             'PW_USE_GAMEMODE': _("Use system GameMode for performance optimization"), | ||||
|             'PW_USE_D3D_EXTRAS': _("Enable forced use of third-party DirectX libraries"), | ||||
|             'PW_FIX_VIDEO_IN_GAME': _("Fix pink-tinted video playback in some games"), | ||||
|             'PW_REDUCE_PULSE_LATENCY': _("Reduce PulseAudio latency to fix intermittent sound"), | ||||
|             'PW_USE_US_LAYOUT': _("Force US keyboard layout"), | ||||
|             'PW_USE_GSTREAMER': _("Use GStreamer for in-game clips (WMF support)"), | ||||
|             'PW_USE_SHADER_CACHE': _("Use WINE shader caching"), | ||||
|             'PW_USE_WINE_DXGI': _("Force use of built-in DXGI library"), | ||||
|             'PW_USE_EAC_AND_BE': _("Enable Easy Anti-Cheat and BattlEye runtimes"), | ||||
|             'PW_USE_SYSTEM_VK_LAYERS': _("Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"), | ||||
|             'PW_USE_OBS_VKCAPTURE': _("Enable OBS Studio capture via obs-vkcapture"), | ||||
|             'PW_DISABLE_COMPOSITING': _("Disable desktop compositing for performance"), | ||||
|             'PW_USE_RUNTIME': _("Use container launch mode (recommended default)"), | ||||
|             'PW_DINPUT_PROTOCOL': _("Force DirectInput protocol instead of XInput"), | ||||
|             'PW_USE_NATIVE_WAYLAND': _("Enable experimental native Wayland support"), | ||||
|             'PW_USE_DXVK_HDR': _("Enable HDR settings under native Wayland"), | ||||
|             'PW_USE_GALLIUM_ZINK': _("Use Gallium Zink (OpenGL via Vulkan)"), | ||||
|             'PW_USE_GALLIUM_NINE': _("Use Gallium Nine (native DirectX 9 for Mesa)"), | ||||
|             'PW_USE_WINED3D_VULKAN': _("Use WineD3D Vulkan backend (Damavand)"), | ||||
|             'PW_USE_SUPPLIED_DXVK_VKD3D': _("Use bundled dxvk/vkd3d from Wine/Proton"), | ||||
|             'PW_USE_SAREK_ASYNC': _("Use async dxvk-sarek (experimental)") | ||||
|         } | ||||
|  | ||||
|     def setup_ui(self): | ||||
|         """Set up the user interface.""" | ||||
|         self.main_layout = QVBoxLayout(self) | ||||
|         self.main_layout.setContentsMargins(10, 10, 10, 10) | ||||
|         self.main_layout.setSpacing(10) | ||||
|  | ||||
|         # Tab widget | ||||
|         self.tab_widget = QTabWidget() | ||||
|         self.main_tab = QWidget() | ||||
|         self.main_tab_layout = QVBoxLayout(self.main_tab) | ||||
|         self.advanced_tab = QWidget() | ||||
|         self.advanced_tab_layout = QVBoxLayout(self.advanced_tab) | ||||
|  | ||||
|         self.tab_widget.addTab(self.main_tab, _("Main")) | ||||
|         self.tab_widget.addTab(self.advanced_tab, _("Advanced")) | ||||
|  | ||||
|         # Таблица настроек | ||||
|         self.settings_table = QTableWidget() | ||||
|         self.settings_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus) | ||||
|         self.settings_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) | ||||
|         self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) | ||||
|         self.settings_table.setColumnCount(3) | ||||
|         self.settings_table.setHorizontalHeaderLabels([_("Setting"), _("Value"), _("Description")]) | ||||
|         self.settings_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) | ||||
|         self.settings_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) | ||||
|         self.settings_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) | ||||
|         self.settings_table.horizontalHeader().resizeSection(1, 100) | ||||
|         self.settings_table.setWordWrap(True) | ||||
|         self.settings_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) | ||||
|         self.settings_table.setTextElideMode(Qt.TextElideMode.ElideNone) | ||||
|         self.settings_table.setStyleSheet(self.theme.WINETRICKS_TABBLE_STYLE) | ||||
|         self.main_tab_layout.addWidget(self.settings_table) | ||||
|  | ||||
|         # Таблица Advanced | ||||
|         self.advanced_table = QTableWidget() | ||||
|         self.advanced_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus) | ||||
|         self.advanced_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) | ||||
|         self.advanced_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) | ||||
|         self.advanced_table.setColumnCount(3) | ||||
|         self.advanced_table.setHorizontalHeaderLabels([_("Setting"), _("Value"), _("Description")]) | ||||
|         self.advanced_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) | ||||
|         self.advanced_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) | ||||
|         self.advanced_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) | ||||
|         self.advanced_table.horizontalHeader().resizeSection(1, 200) | ||||
|         self.advanced_table.setWordWrap(True) | ||||
|         self.advanced_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) | ||||
|         self.advanced_table.setTextElideMode(Qt.TextElideMode.ElideNone) | ||||
|         self.advanced_table.setStyleSheet(self.theme.WINETRICKS_TABBLE_STYLE) | ||||
|         self.advanced_tab_layout.addWidget(self.advanced_table) | ||||
|  | ||||
|         self.main_layout.addWidget(self.tab_widget) | ||||
|  | ||||
|         # Кнопки | ||||
|         button_layout = QHBoxLayout() | ||||
|         self.apply_button = AutoSizeButton(_("Apply"), icon=ThemeManager().get_icon("apply")) | ||||
|         self.cancel_button = AutoSizeButton(_("Cancel"), icon=ThemeManager().get_icon("cancel")) | ||||
|         self.apply_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) | ||||
|         self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) | ||||
|         button_layout.addWidget(self.apply_button) | ||||
|         button_layout.addWidget(self.cancel_button) | ||||
|         self.main_layout.addLayout(button_layout) | ||||
|  | ||||
|         self.apply_button.clicked.connect(self.apply_changes) | ||||
|         self.cancel_button.clicked.connect(self.reject) | ||||
|  | ||||
|  | ||||
|     def load_current_settings(self): | ||||
|         """Load available toggles first, then current settings.""" | ||||
|         process = QProcess(self) | ||||
|         process.finished.connect(self.on_list_db_finished) | ||||
|         process.start(self.start_sh[0], ["cli", "--list-db"]) | ||||
|  | ||||
|  | ||||
|     def on_list_db_finished(self, exit_code, exit_status): | ||||
|         """Handle --list-db output and extract available keys and system info.""" | ||||
|         process = cast(QProcess, self.sender()) | ||||
|         self.available_keys = set() | ||||
|         self.blocked_keys = set() | ||||
|         if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit: | ||||
|             output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore') | ||||
|             lines = output.splitlines() | ||||
|             self.numa_nodes = {} | ||||
|             self.is_amd = False | ||||
|             self.logical_core_options = [] | ||||
|             self.locale_options = [] | ||||
|             self.amd_vulkan_drivers = [] | ||||
|             for line in lines: | ||||
|                 line_stripped = line.strip() | ||||
|                 if not line_stripped: | ||||
|                     continue | ||||
|                 if re.match(r'^[A-Z_0-9]+=[^=]+$', line_stripped) and not line_stripped.startswith('PW_'): | ||||
|                     # System info | ||||
|                     k, v = line_stripped.split('=', 1) | ||||
|                     if k.startswith('NUMA_NODE_'): | ||||
|                         node_id = k[10:] | ||||
|                         self.numa_nodes[node_id] = v | ||||
|                     elif k == 'IS_AMD': | ||||
|                         self.is_amd = v.lower() == 'true' | ||||
|                     elif k == 'LOGICAL_CORE_OPTIONS': | ||||
|                         self.logical_core_options = v.split('!') if v else [] | ||||
|                     elif k == 'LOCALE_LIST': | ||||
|                         self.locale_options = v.split('!') if v else [] | ||||
|                     elif k == 'AMD_VULKAN_DRIVER_LIST': | ||||
|                         self.amd_vulkan_drivers = v.split('!') if v else [] | ||||
|                     continue | ||||
|                 if line_stripped.startswith('PW_'): | ||||
|                     parts = line_stripped.split(maxsplit=1) | ||||
|                     key = parts[0] | ||||
|                     self.available_keys.add(key) | ||||
|                     if len(parts) > 1 and 'blocked' in parts[1]: | ||||
|                         self.blocked_keys.add(key) | ||||
|  | ||||
|             # Показываем только пересечение | ||||
|             self.available_keys &= set(self.toggle_settings.keys()) | ||||
|  | ||||
|         # Загружаем текущие настройки | ||||
|         process = QProcess(self) | ||||
|         process.finished.connect(self.on_show_ppdb_finished) | ||||
|         process.start(self.start_sh[0], ["cli", "--show-ppdb", f"{self.exe_path}.ppdb"]) | ||||
|  | ||||
|     def on_show_ppdb_finished(self, exit_code, exit_status): | ||||
|         """Handle --show-ppdb output.""" | ||||
|         process = cast(QProcess, self.sender()) | ||||
|         if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit: | ||||
|             # Fallback to defaults if load fails | ||||
|             for key in self.toggle_settings: | ||||
|                 self.current_settings[key] = '0' | ||||
|             for adv_key in ['PW_WINDOWS_VER', 'WINEDLLOVERRIDES', 'LAUNCH_PARAMETERS', | ||||
|                             'PW_WINE_CPU_TOPOLOGY', 'PW_MESA_GL_VERSION_OVERRIDE', | ||||
|                             'PW_VKD3D_FEATURE_LEVEL', 'PW_LOCALE_SELECT', | ||||
|                             'PW_MESA_VK_WSI_PRESENT_MODE', 'PW_AMD_VULKAN_USE', | ||||
|                             'PW_CPU_NUMA_NODE_INDEX']: | ||||
|                 self.current_settings[adv_key] = 'disabled' if 'TOPOLOGY' in adv_key or 'SELECT' in adv_key or 'MODE' in adv_key or 'LEVEL' in adv_key or 'GL_VERSION' in adv_key or 'NUMA' in adv_key else '' | ||||
|         else: | ||||
|             output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore').strip() | ||||
|             self.current_settings = {} | ||||
|             for line in output.split('\n'): | ||||
|                 line_stripped = line.strip() | ||||
|                 if '=' in line_stripped: | ||||
|                     # Parse all KEY=VALUE lines, not just specific prefixes, to catch more | ||||
|                     try: | ||||
|                         key, val = line_stripped.split('=', 1) | ||||
|                         if key in self.toggle_settings or key in ['PW_WINDOWS_VER', 'WINEDLLOVERRIDES', 'LAUNCH_PARAMETERS', | ||||
|                                                                  'PW_WINE_CPU_TOPOLOGY', 'PW_MESA_GL_VERSION_OVERRIDE', | ||||
|                                                                  'PW_VKD3D_FEATURE_LEVEL', 'PW_LOCALE_SELECT', | ||||
|                                                                  'PW_MESA_VK_WSI_PRESENT_MODE', 'PW_AMD_VULKAN_USE', | ||||
|                                                                  'PW_CPU_NUMA_NODE_INDEX', 'PW_TASKSET_SLR']: | ||||
|                             self.current_settings[key] = val | ||||
|                     except ValueError: | ||||
|                         continue | ||||
|  | ||||
|         # Force blocked settings to '0' | ||||
|         for key in self.blocked_keys: | ||||
|             self.current_settings[key] = '0' | ||||
|  | ||||
|         self.original_values = self.current_settings.copy() | ||||
|         for key in set(self.toggle_settings.keys()): | ||||
|             self.original_values.setdefault(key, '0') | ||||
|  | ||||
|         self.populate_table() | ||||
|         self.populate_advanced() | ||||
|  | ||||
|     def populate_table(self): | ||||
|         """Populate the table with settings that are available in both lists.""" | ||||
|         self.settings_table.setRowCount(0) | ||||
|         self.value_widgets.clear() | ||||
|         self.settings_table.verticalHeader().setVisible(False) | ||||
|  | ||||
|         visible_keys = sorted(self.available_keys) if self.available_keys else sorted(self.toggle_settings.keys()) | ||||
|  | ||||
|         for toggle in visible_keys: | ||||
|             description = self.toggle_settings.get(toggle) | ||||
|             if not description: | ||||
|                 continue | ||||
|  | ||||
|             row = self.settings_table.rowCount() | ||||
|             self.settings_table.insertRow(row) | ||||
|  | ||||
|             name_item = QTableWidgetItem(toggle) | ||||
|             name_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled) | ||||
|  | ||||
|             current_val = self.current_settings.get(toggle, '0') | ||||
|             is_blocked = toggle in self.blocked_keys | ||||
|             checkbox = QTableWidgetItem() | ||||
|             checkbox.setFlags(checkbox.flags() | Qt.ItemFlag.ItemIsUserCheckable) | ||||
|             check_state = Qt.CheckState.Checked if current_val == '1' and not is_blocked else Qt.CheckState.Unchecked | ||||
|             checkbox.setCheckState(check_state) | ||||
|             checkbox.setTextAlignment(Qt.AlignmentFlag.AlignCenter) | ||||
|             if is_blocked: | ||||
|                 checkbox.setFlags(checkbox.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) | ||||
|                 checkbox.setBackground(QColor(240, 240, 240)) | ||||
|                 name_item.setForeground(QColor(128, 128, 128)) | ||||
|             self.settings_table.setItem(row, 1, checkbox) | ||||
|  | ||||
|             desc_item = QTableWidgetItem(description) | ||||
|             desc_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled) | ||||
|             desc_item.setToolTip(description) | ||||
|             desc_item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) | ||||
|             if is_blocked: | ||||
|                 desc_item.setForeground(QColor(128, 128, 128)) | ||||
|             self.settings_table.setItem(row, 2, desc_item) | ||||
|  | ||||
|             self.settings_table.setItem(row, 0, name_item) | ||||
|             self.value_widgets[(row, 1)] = checkbox | ||||
|  | ||||
|         self.settings_table.resizeRowsToContents() | ||||
|         if self.settings_table.rowCount() > 0: | ||||
|             self.settings_table.setCurrentCell(0, 0) | ||||
|             self.settings_table.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|  | ||||
|     def populate_advanced(self): | ||||
|         """Populate the advanced tab with table format.""" | ||||
|         self.advanced_table.setRowCount(0) | ||||
|         self.advanced_widgets.clear() | ||||
|         self.original_display_values = {} | ||||
|         self.advanced_table.verticalHeader().setVisible(False) | ||||
|  | ||||
|         current = self.current_settings | ||||
|         disabled_text = _('disabled') | ||||
|  | ||||
|         # Define advanced settings configuration | ||||
|         advanced_settings = [] | ||||
|  | ||||
|         # 1. Windows version | ||||
|         advanced_settings.append({ | ||||
|             'key': 'PW_WINDOWS_VER', | ||||
|             'name': _("Windows version"), | ||||
|             'description': _("Changing the WINDOWS emulation version may be required to run older games. WINDOWS versions below 10 do not support new games with DirectX 12"), | ||||
|             'type': 'combo', | ||||
|             'options': ['11', '10', '7', 'XP'], | ||||
|             'default': '10' | ||||
|         }) | ||||
|  | ||||
|         # 2. Forced to use/disable libraries | ||||
|         advanced_settings.append({ | ||||
|             'key': 'WINEDLLOVERRIDES', | ||||
|             'name': _("DLL Overrides"), | ||||
|             'description': _("Forced to use/disable the library only for the given application.\n\nA brief instruction:\n* libraries are written WITHOUT the .dll file extension\n* libraries are separated by semicolons - ;\n* library=n - use the WINDOWS (third-party) library\n* library=b - use WINE (built-in) library\n* library=n,b - use WINDOWS library and then WINE\n* library=b,n - use WINE library and then WINDOWS\n* library= - disable the use of this library\n\nExample: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"), | ||||
|             'type': 'text', | ||||
|             'default': '' | ||||
|         }) | ||||
|  | ||||
|         # 3. Launch arguments | ||||
|         advanced_settings.append({ | ||||
|             'key': 'LAUNCH_PARAMETERS', | ||||
|             'name': _("Launch Arguments"), | ||||
|             'description': _("Adding an argument after the .exe file, just like you would add an argument in a shortcut on a WINDOWS system.\n\nExample: -dx11 -skipintro 1"), | ||||
|             'type': 'text', | ||||
|             'default': '' | ||||
|         }) | ||||
|  | ||||
|         # 4. CPU cores limit | ||||
|         advanced_settings.append({ | ||||
|             'key': 'PW_WINE_CPU_TOPOLOGY', | ||||
|             'name': _("CPU Cores Limit"), | ||||
|             'description': _("Limiting the number of CPU cores is useful for Unity games (It is recommended to set the value equal to 8)"), | ||||
|             'type': 'combo', | ||||
|             'options': [disabled_text] + self.logical_core_options, | ||||
|             'default': disabled_text | ||||
|         }) | ||||
|  | ||||
|         # 5. OpenGL version | ||||
|         advanced_settings.append({ | ||||
|             'key': 'PW_MESA_GL_VERSION_OVERRIDE', | ||||
|             'name': _("OpenGL Version"), | ||||
|             'description': _("You can select the required OpenGL version, some games require a forced Compatibility Profile (COMP)."), | ||||
|             'type': 'combo', | ||||
|             'options': [disabled_text, '4.6COMPAT', '4.5COMPAT', '4.3COMPAT', '4.1COMPAT', '3.3COMPAT', '3.2COMPAT'], | ||||
|             'default': disabled_text | ||||
|         }) | ||||
|  | ||||
|         # 6. VKD3D feature level | ||||
|         advanced_settings.append({ | ||||
|             'key': 'PW_VKD3D_FEATURE_LEVEL', | ||||
|             'name': _("VKD3D Feature Level"), | ||||
|             'description': _("You can set a forced feature level VKD3D for games on DirectX12"), | ||||
|             'type': 'combo', | ||||
|             'options': [disabled_text, '12_2', '12_1', '12_0', '11_1', '11_0'], | ||||
|             'default': disabled_text | ||||
|         }) | ||||
|  | ||||
|         # 7. Locale | ||||
|         advanced_settings.append({ | ||||
|             'key': 'PW_LOCALE_SELECT', | ||||
|             'name': _("Locale"), | ||||
|             'description': _("Force certain locale for an app. Fixes encoding issues in legacy software"), | ||||
|             'type': 'combo', | ||||
|             'options': [disabled_text] + self.locale_options, | ||||
|             'default': disabled_text | ||||
|         }) | ||||
|  | ||||
|         # 8. Present mode | ||||
|         advanced_settings.append({ | ||||
|             'key': 'PW_MESA_VK_WSI_PRESENT_MODE', | ||||
|             'name': _("Window Mode"), | ||||
|             'description': _("Window mode (for Vulkan and OpenGL):\nfifo - First in, first out. Limits the frame rate + no tearing. (VSync)\nimmediate - Unlimited frame rate + tearing.\nmailbox - Triple buffering. Unlimited frame rate + no tearing.\nrelaxed - Same as fifo but allows tearing when below the monitors refresh rate."), | ||||
|             'type': 'combo', | ||||
|             'options': [disabled_text, 'fifo', 'immediate', 'mailbox', 'relaxed'], | ||||
|             'default': disabled_text | ||||
|         }) | ||||
|  | ||||
|         # 9. AMD Vulkan (always show, block if not applicable) | ||||
|         amd_options = [disabled_text] + self.amd_vulkan_drivers if self.is_amd and self.amd_vulkan_drivers else [disabled_text] | ||||
|         advanced_settings.append({ | ||||
|             'key': 'PW_AMD_VULKAN_USE', | ||||
|             'name': _("AMD Vulkan Driver"), | ||||
|             'description': _("Select needed AMD vulkan implementation. Choosing which implementation of vulkan will be used to run the game"), | ||||
|             'type': 'combo', | ||||
|             'options': amd_options, | ||||
|             'default': disabled_text | ||||
|         }) | ||||
|  | ||||
|         # 10. NUMA node (always show if numa_nodes exist, block if <=1) | ||||
|         numa_ids = sorted(self.numa_nodes.keys()) | ||||
|         numa_options = [disabled_text] + numa_ids if len(numa_ids) > 1 else [disabled_text] | ||||
|         advanced_settings.append({ | ||||
|             'key': 'PW_CPU_NUMA_NODE_INDEX', | ||||
|             'name': _("NUMA Node"), | ||||
|             'description': _("NUMA node for CPU affinity. In multi-core systems, CPUs are split into NUMA nodes, each with its own local memory and cores. Binding a game to a single node reduces memory-access latency and limits costly core-to-core switches."), | ||||
|             'type': 'combo', | ||||
|             'options': numa_options, | ||||
|             'default': disabled_text | ||||
|         }) | ||||
|  | ||||
|         # Populate table | ||||
|         for setting in advanced_settings: | ||||
|             row = self.advanced_table.rowCount() | ||||
|             self.advanced_table.insertRow(row) | ||||
|  | ||||
|             # Name column | ||||
|             name_item = QTableWidgetItem(setting['name']) | ||||
|             name_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled) | ||||
|             self.advanced_table.setItem(row, 0, name_item) | ||||
|  | ||||
|             # Value column (widget) | ||||
|             if setting['type'] == 'combo': | ||||
|                 combo = QComboBox() | ||||
|                 combo.addItems(setting['options']) | ||||
|  | ||||
|                 # Get current value | ||||
|                 current_raw = current.get(setting['key'], setting['default']) | ||||
|                 if setting['key'] == 'PW_WINE_CPU_TOPOLOGY': | ||||
|                     current_val = disabled_text if current_raw == 'disabled' else (current_raw.split(':')[0] if isinstance(current_raw, str) and ':' in current_raw else current_raw) | ||||
|                 elif setting['key'] == 'PW_AMD_VULKAN_USE': | ||||
|                     current_val = disabled_text if not current_raw or current_raw == '' else current_raw | ||||
|                 else: | ||||
|                     current_val = disabled_text if current_raw == 'disabled' else current_raw | ||||
|  | ||||
|                 if current_val not in setting['options']: | ||||
|                     combo.addItem(current_val) | ||||
|                 combo.setCurrentText(current_val) | ||||
|  | ||||
|                 # Block if only disabled option | ||||
|                 if len(setting['options']) == 1: | ||||
|                     combo.setEnabled(False) | ||||
|  | ||||
|                 self.advanced_table.setCellWidget(row, 1, combo) | ||||
|                 self.advanced_widgets[setting['key']] = combo | ||||
|                 self.original_display_values[setting['key']] = current_val | ||||
|  | ||||
|             elif setting['type'] == 'text': | ||||
|                 text_edit = QTextEdit() | ||||
|                 current_val = current.get(setting['key'], setting['default']) | ||||
|                 text_edit.setPlainText(current_val) | ||||
|  | ||||
|                 self.advanced_table.setCellWidget(row, 1, text_edit) | ||||
|                 self.advanced_widgets[setting['key']] = text_edit | ||||
|                 self.original_display_values[setting['key']] = current_val | ||||
|  | ||||
|             # Description column | ||||
|             desc_item = QTableWidgetItem(setting['description']) | ||||
|             desc_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled) | ||||
|             desc_item.setToolTip(setting['description']) | ||||
|             desc_item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) | ||||
|             self.advanced_table.setItem(row, 2, desc_item) | ||||
|  | ||||
|         self.advanced_table.resizeRowsToContents() | ||||
|         if self.advanced_table.rowCount() > 0: | ||||
|             self.advanced_table.setCurrentCell(0, 0) | ||||
|  | ||||
|     def apply_changes(self): | ||||
|         """Apply changes by collecting diffs from both main and advanced tabs.""" | ||||
|         changes = [] | ||||
|  | ||||
|         # --- 1. Обычные (toggle) настройки --- | ||||
|         for key, orig_val in self.original_values.items(): | ||||
|             if key in self.blocked_keys: | ||||
|                 continue  # Skip blocked keys | ||||
|             row = -1 | ||||
|             for r in range(self.settings_table.rowCount()): | ||||
|                 item0 = self.settings_table.item(r, 0) | ||||
|                 if item0 and item0.text() == key: | ||||
|                     row = r | ||||
|                     break | ||||
|             if row == -1: | ||||
|                 continue | ||||
|  | ||||
|             item = self.settings_table.item(row, 1) | ||||
|             if not item: | ||||
|                 continue | ||||
|  | ||||
|             new_val = '1' if item.checkState() == Qt.CheckState.Checked else '0' | ||||
|             if new_val != orig_val: | ||||
|                 changes.append(f"{key}={new_val}") | ||||
|  | ||||
|         # --- 2. Advanced настройки --- | ||||
|         for key, widget in self.advanced_widgets.items(): | ||||
|             orig_val = self.original_display_values.get(key, '') | ||||
|             if isinstance(widget, QComboBox): | ||||
|                 new_val = widget.currentText() | ||||
|                 # приведение disabled к 'disabled' | ||||
|                 if new_val.lower() == _('disabled').lower(): | ||||
|                     new_val = 'disabled' | ||||
|             elif isinstance(widget, QTextEdit): | ||||
|                 new_val = widget.toPlainText().strip() | ||||
|             else: | ||||
|                 continue | ||||
|  | ||||
|             if new_val != orig_val: | ||||
|                 changes.append(f"{key}={new_val}") | ||||
|  | ||||
|         # --- 3. Проверка на изменения --- | ||||
|         if not changes: | ||||
|             QMessageBox.information(self, _("Info"), _("No changes to apply.")) | ||||
|             return | ||||
|  | ||||
|         # --- 4. Запуск процесса сохранения --- | ||||
|         process = QProcess(self) | ||||
|         process.finished.connect(self.on_edit_db_finished) | ||||
|         args = ["cli", "--edit-db", self.exe_path] + changes | ||||
|         process.start(self.start_sh[0], args) | ||||
|         self.apply_button.setEnabled(False) | ||||
|  | ||||
|     def on_edit_db_finished(self, exit_code, exit_status): | ||||
|         """Handle --edit-db output.""" | ||||
|         process = cast(QProcess, self.sender()) | ||||
|         self.apply_button.setEnabled(True) | ||||
|         if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit: | ||||
|             error_output = bytes(process.readAllStandardError().data()).decode('utf-8', 'ignore') | ||||
|             QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs.")) | ||||
|             logger.error(f"Failed to apply changes: {error_output}") | ||||
|         else: | ||||
|             self.load_current_settings() | ||||
|             QMessageBox.information(self, _("Success"), _("Settings updated successfully.")) | ||||
|  | ||||
|     def closeEvent(self, event): | ||||
|         super().closeEvent(event) | ||||
|  | ||||
|     def reject(self): | ||||
|         super().reject() | ||||
|   | ||||
| @@ -13,7 +13,7 @@ from portprotonqt.localization import get_egs_language, _ | ||||
| from portprotonqt.logger import get_logger | ||||
| from portprotonqt.image_utils import load_pixmap_async | ||||
| from portprotonqt.time_utils import parse_playtime_file, format_playtime, get_last_launch, get_last_launch_timestamp | ||||
| from portprotonqt.config_utils import get_portproton_location | ||||
| from portprotonqt.config_utils import get_portproton_location, get_portproton_start_command | ||||
| from portprotonqt.steam_api import ( | ||||
|     get_weanticheatyet_status_async, get_steam_apps_and_index_async, get_protondb_tier_async, | ||||
|     search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail, call_steam_api | ||||
| @@ -254,14 +254,7 @@ def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callba | ||||
|         return | ||||
|  | ||||
|     # Determine wrapper | ||||
|     wrapper = "flatpak run ru.linux_gaming.PortProton" | ||||
|     start_sh_path = os.path.join(portproton_dir, "data", "scripts", "start.sh") | ||||
|     if portproton_dir is not None and ".var" not in portproton_dir: | ||||
|         wrapper = start_sh_path | ||||
|         if not os.path.exists(start_sh_path): | ||||
|             logger.error(f"start.sh not found at {start_sh_path}") | ||||
|             callback((False, f"start.sh not found at {start_sh_path}")) | ||||
|             return | ||||
|     wrapper = get_portproton_start_command() | ||||
|  | ||||
|     # Create launch script | ||||
|     steam_scripts_dir = os.path.join(portproton_dir, "steam_scripts") | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| from PySide6.QtGui import QPainter, QColor, QDesktopServices | ||||
| from PySide6.QtCore import Signal, Property, Qt, QUrl | ||||
| from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer | ||||
| from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel | ||||
| from collections.abc import Callable | ||||
| from portprotonqt.image_utils import load_pixmap_async, round_corners | ||||
| @@ -404,6 +404,13 @@ class GameCard(QFrame): | ||||
|             self.favoriteLabel.setText("☆") | ||||
|         self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE) | ||||
|  | ||||
|         parent = self.parent() | ||||
|         while parent: | ||||
|             if hasattr(parent, 'game_library_manager'): | ||||
|                 QTimer.singleShot(0, parent.game_library_manager.update_game_grid) # type: ignore[attr-defined] | ||||
|                 break | ||||
|             parent = parent.parent() | ||||
|  | ||||
|     def toggle_favorite(self): | ||||
|         favorites = read_favorites() | ||||
|         if self.is_favorite: | ||||
|   | ||||
| @@ -33,6 +33,7 @@ class MainWindowProtocol(Protocol): | ||||
|     # Required attributes | ||||
|     searchEdit: CustomLineEdit | ||||
|     _last_card_width: int | ||||
|     card_width: int | ||||
|     current_hovered_card: GameCard | None | ||||
|     current_focused_card: GameCard | None | ||||
|     gamesListWidget: QWidget | None | ||||
| @@ -128,6 +129,8 @@ class GameLibraryManager: | ||||
|         self.card_width = self.sizeSlider.value() | ||||
|         self.sizeSlider.setToolTip(f"{self.card_width} px") | ||||
|         save_card_size(self.card_width) | ||||
|         self.main_window.card_width = self.card_width | ||||
|         self.main_window._last_card_width = self.card_width | ||||
|         for card in self.game_card_cache.values(): | ||||
|             card.update_card_size(self.card_width) | ||||
|         self.update_game_grid() | ||||
|   | ||||
| @@ -83,6 +83,43 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Ошибка обработки URL {cover}: {e}") | ||||
|  | ||||
|         # SteamGridDB (SGDB) | ||||
|         if cover and cover.startswith("https://cdn2.steamgriddb.com"): | ||||
|             try: | ||||
|                 parts = cover.split("/") | ||||
|                 filename = parts[-1] if parts else "sgdb_cover.png" | ||||
|                 # SGDB ссылки содержат уникальный хеш в названии — используем как имя | ||||
|                 local_path = os.path.join(image_folder, filename) | ||||
|  | ||||
|                 if os.path.exists(local_path): | ||||
|                     pixmap = QPixmap(local_path) | ||||
|                     finish_with(pixmap) | ||||
|                     return | ||||
|  | ||||
|                 def on_downloaded(result: str | None): | ||||
|                     pixmap = QPixmap() | ||||
|                     if result and os.path.exists(result): | ||||
|                         pixmap.load(result) | ||||
|                     if pixmap.isNull(): | ||||
|                         placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name) | ||||
|                         if placeholder_path and QFile.exists(placeholder_path): | ||||
|                             pixmap.load(placeholder_path) | ||||
|                         else: | ||||
|                             pixmap = QPixmap(width, height) | ||||
|                             pixmap.fill(QColor("#333333")) | ||||
|                             painter = QPainter(pixmap) | ||||
|                             painter.setPen(QPen(QColor("white"))) | ||||
|                             painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image") | ||||
|                             painter.end() | ||||
|                     finish_with(pixmap) | ||||
|  | ||||
|                 logger.info("Downloading SGDB cover for %s -> %s", app_name or "unknown", filename) | ||||
|                 downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded) | ||||
|                 return | ||||
|  | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Ошибка обработки SGDB URL {cover}: {e}") | ||||
|  | ||||
|         if cover and cover.startswith(("http://", "https://")): | ||||
|             try: | ||||
|                 local_path = os.path.join(image_folder, f"{app_name}.jpg") | ||||
|   | ||||
| @@ -4,7 +4,7 @@ import os | ||||
| from typing import Protocol, cast | ||||
| from evdev import InputDevice, InputEvent, ecodes, list_devices, ff | ||||
| from enum import Enum | ||||
| from pyudev import Context, Monitor, MonitorObserver, Device | ||||
| from pyudev import Context, Monitor, Device, Devices | ||||
| from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView, QTableWidgetItem | ||||
| from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer | ||||
| from PySide6.QtGui import QKeyEvent, QMouseEvent | ||||
| @@ -76,6 +76,7 @@ class InputManager(QObject): | ||||
|     button_event = Signal(int, int)  # Signal for button events: (code, value) where value=1 (press), 0 (release) | ||||
|     dpad_moved = Signal(int, int, float)  # Signal for D-pad movements | ||||
|     toggle_fullscreen = Signal(bool)  # Signal for toggling fullscreen mode (True for fullscreen, False for normal) | ||||
|     gamepad_hotplug = Signal(str)  # 'add' or 'remove' | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
| @@ -1436,74 +1437,262 @@ class InputManager(QObject): | ||||
|         return super().eventFilter(obj, event) | ||||
|  | ||||
|     def init_gamepad(self) -> None: | ||||
|         self.udev_context = Context() | ||||
|         self.Devices = Devices | ||||
|         self.monitor_ready = False | ||||
|         self.monitor_event = threading.Event() | ||||
|  | ||||
|         # Подключаем сигнал hotplug к обработчику в главном потоке | ||||
|         self.gamepad_hotplug.connect(self._on_gamepad_hotplug) | ||||
|  | ||||
|         # Debounce timer для отложенной проверки геймпада (в главном потоке Qt) | ||||
|         self.gamepad_check_timer = QTimer() | ||||
|         self.gamepad_check_timer.setSingleShot(True) | ||||
|         self.gamepad_check_timer.timeout.connect(self.check_gamepad) | ||||
|  | ||||
|         # Первоначальная проверка | ||||
|         self.check_gamepad() | ||||
|  | ||||
|         # Запускаем udev monitor в отдельном потоке | ||||
|         threading.Thread(target=self.run_udev_monitor, daemon=True).start() | ||||
|         logger.info("Gamepad support initialized with hotplug (evdev + pyudev)") | ||||
|  | ||||
|     def run_udev_monitor(self) -> None: | ||||
|         """ | ||||
|         Безопасный неблокирующий udev monitor для геймпадов. | ||||
|         Использует select.poll() вместо блокирующего monitor.poll(). | ||||
|         """ | ||||
|         try: | ||||
|             context = Context() | ||||
|             monitor = Monitor.from_netlink(context) | ||||
|             logger.info("Starting udev monitor...") | ||||
|             monitor = Monitor.from_netlink(self.udev_context) | ||||
|             monitor.filter_by(subsystem='input') | ||||
|             observer = MonitorObserver(monitor, self.handle_udev_event) | ||||
|             observer.start() | ||||
|  | ||||
|             try: | ||||
|                 monitor.start() | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Failed to start udev monitor: {e}") | ||||
|                 return | ||||
|  | ||||
|             import select | ||||
|             fd = monitor.fileno() | ||||
|             poller = select.poll() | ||||
|             poller.register(fd, select.POLLIN) | ||||
|  | ||||
|             # Короткий дренаж событий при запуске (0.5 сек) | ||||
|             drain_start = time.time() | ||||
|             drained_count = 0 | ||||
|             while time.time() - drain_start < 0.5: | ||||
|                 events = poller.poll(100) | ||||
|                 if not events: | ||||
|                     continue | ||||
|                 try: | ||||
|                     _ = monitor.poll(timeout=0)  # просто читаем, не обрабатываем | ||||
|                     drained_count += 1 | ||||
|                 except Exception: | ||||
|                     break | ||||
|  | ||||
|             self.monitor_ready = True | ||||
|             self.monitor_event.set() | ||||
|             logger.info(f"Drained {drained_count} initial events, now monitoring hotplug...") | ||||
|  | ||||
|             # Основной цикл | ||||
|             while self.running: | ||||
|                 time.sleep(1) | ||||
|                 events = poller.poll(1000)  # 1 сек таймаут | ||||
|                 if not events: | ||||
|                     continue  # просто ждём, не блокируем | ||||
|  | ||||
|                 try: | ||||
|                     device = monitor.poll(timeout=0) | ||||
|                 except Exception as e: | ||||
|                     logger.debug(f"Monitor poll failed: {e}") | ||||
|                     continue | ||||
|  | ||||
|                 if not device: | ||||
|                     continue | ||||
|  | ||||
|                 action = device.action | ||||
|                 if action and self._is_joystick_device(device): | ||||
|                     logger.info(f"Joystick hotplug event: {action} for {device.sys_name}") | ||||
|                     # отправляем сигнал в Qt-поток | ||||
|                     self.handle_udev_event(action, device) | ||||
|  | ||||
|             logger.info("udev monitor stopped gracefully") | ||||
|  | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error in udev monitor: {e}", exc_info=True) | ||||
|  | ||||
|     def _is_joystick_device(self, device: Device) -> bool: | ||||
|         """ | ||||
|         Быстрая проверка: является ли устройство джойстиком. | ||||
|         Проверяет ID_INPUT_JOYSTICK из udev базы данных. | ||||
|         """ | ||||
|         try: | ||||
|             # Проверяем свойство ID_INPUT_JOYSTICK | ||||
|             if device.get('ID_INPUT_JOYSTICK') == '1': | ||||
|                 return True | ||||
|  | ||||
|             # Дополнительно: проверяем родительские устройства | ||||
|             # (некоторые контроллеры имеют свойство только у родителя) | ||||
|             parent = device.parent | ||||
|             if parent and parent.get('ID_INPUT_JOYSTICK') == '1': | ||||
|                 return True | ||||
|  | ||||
|             return False | ||||
|         except Exception as e: | ||||
|             logger.debug(f"Error checking joystick device: {e}") | ||||
|             return False | ||||
|  | ||||
|  | ||||
|     def handle_udev_event(self, action: str, device: Device) -> None: | ||||
|         """ | ||||
|         Обработчик udev событий для джойстиков. | ||||
|         Отправляет сигнал в главный поток Qt вместо прямого вызова QTimer. | ||||
|         """ | ||||
|         try: | ||||
|             if action == 'add': | ||||
|                 time.sleep(0.1) | ||||
|                 self.check_gamepad() | ||||
|                 # Отправляем сигнал в главный поток Qt | ||||
|                 # QTimer будет запущен там безопасно | ||||
|                 logger.debug("Emitting gamepad add signal") | ||||
|                 self.gamepad_hotplug.emit('add') | ||||
|  | ||||
|             elif action == 'remove' and self.gamepad: | ||||
|                 if not any(self.gamepad.path == path for path in list_devices()): | ||||
|                     logger.info("Gamepad disconnected") | ||||
|                     self.stop_rumble() | ||||
|                     self.gamepad = None | ||||
|                     if self.gamepad_thread: | ||||
|                         self.gamepad_thread.join() | ||||
|                     if read_auto_fullscreen_gamepad() and not read_fullscreen_config(): | ||||
|                         self.toggle_fullscreen.emit(False) | ||||
|                 # Проверяем конкретно наш геймпад по пути устройства | ||||
|                 device_node = device.device_node  # например, /dev/input/event3 | ||||
|  | ||||
|                 if device_node and self.gamepad.path == device_node: | ||||
|                     logger.info(f"Connected gamepad disconnected: {device_node}") | ||||
|                     # Отправляем сигнал в главный поток | ||||
|                     self.gamepad_hotplug.emit('remove') | ||||
|  | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error handling udev event: {e}", exc_info=True) | ||||
|  | ||||
|  | ||||
|     def _on_gamepad_hotplug(self, action: str) -> None: | ||||
|         """ | ||||
|         Обработчик сигнала hotplug, выполняется в главном потоке Qt. | ||||
|         Безопасно работает с QTimer. | ||||
|         """ | ||||
|         try: | ||||
|             if action == 'add': | ||||
|                 # Debounce: откладываем проверку на 200ms | ||||
|                 # Множественные события за короткое время объединяются в один вызов | ||||
|                 logger.debug("Scheduling gamepad check (debounced)") | ||||
|                 self.gamepad_check_timer.start(200) | ||||
|  | ||||
|             elif action == 'remove': | ||||
|                 # Немедленная обработка отключения | ||||
|                 self.stop_rumble() | ||||
|                 self.gamepad = None | ||||
|  | ||||
|                 if self.gamepad_thread: | ||||
|                     self.gamepad_thread.join(timeout=2.0) | ||||
|  | ||||
|                 if read_auto_fullscreen_gamepad() and not read_fullscreen_config(): | ||||
|                     self.toggle_fullscreen.emit(False) | ||||
|  | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error in hotplug handler: {e}", exc_info=True) | ||||
|  | ||||
|     def check_gamepad(self) -> None: | ||||
|         """ | ||||
|         Проверка и подключение геймпада. | ||||
|         Вызывается из главного потока Qt через QTimer (debounced). | ||||
|         """ | ||||
|         try: | ||||
|             new_gamepad = self.find_gamepad() | ||||
|             if new_gamepad and new_gamepad != self.gamepad: | ||||
|                 logger.info(f"Gamepad connected: {new_gamepad.name}") | ||||
|  | ||||
|             if new_gamepad: | ||||
|                 if not self.gamepad or new_gamepad.path != self.gamepad.path: | ||||
|                     logger.info(f"Gamepad connected: {new_gamepad.name} at {new_gamepad.path}") | ||||
|                     self.stop_rumble() | ||||
|                     self.gamepad = new_gamepad | ||||
|  | ||||
|                     if self.gamepad_thread and self.gamepad_thread.is_alive(): | ||||
|                         self.gamepad_thread.join(timeout=2.0) | ||||
|  | ||||
|                     def start_monitoring(): | ||||
|                         # Ожидание готовности udev monitor без busy-wait | ||||
|                         if not self.monitor_event.wait(timeout=2.0): | ||||
|                             logger.warning("Timeout waiting for udev monitor readiness") | ||||
|                         self.monitor_gamepad() | ||||
|  | ||||
|                     self.gamepad_thread = threading.Thread( | ||||
|                         target=start_monitoring, | ||||
|                         daemon=True | ||||
|                     ) | ||||
|                     self.gamepad_thread.start() | ||||
|  | ||||
|                     # Автоматический фуллскрин при подключении геймпада | ||||
|                     if read_auto_fullscreen_gamepad() and not read_fullscreen_config(): | ||||
|                         self.toggle_fullscreen.emit(True) | ||||
|  | ||||
|             elif self.gamepad and not any(self.gamepad.path == path for path in list_devices()): | ||||
|                 logger.info("Gamepad no longer detected") | ||||
|                 self.stop_rumble() | ||||
|                 self.gamepad = new_gamepad | ||||
|                 if self.gamepad_thread: | ||||
|                     self.gamepad_thread.join() | ||||
|                 self.gamepad_thread = threading.Thread(target=self.monitor_gamepad, daemon=True) | ||||
|                 self.gamepad_thread.start() | ||||
|                 # Send signal for fullscreen mode only if: | ||||
|                 # 1. auto_fullscreen_gamepad is enabled | ||||
|                 # 2. fullscreen is not already enabled (to avoid conflict) | ||||
|                 self.gamepad = None | ||||
|  | ||||
|                 if self.gamepad_thread and self.gamepad_thread.is_alive(): | ||||
|                     self.gamepad_thread.join(timeout=2.0) | ||||
|  | ||||
|                 if read_auto_fullscreen_gamepad() and not read_fullscreen_config(): | ||||
|                     self.toggle_fullscreen.emit(True) | ||||
|                     self.toggle_fullscreen.emit(False) | ||||
|  | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error checking gamepad: {e}", exc_info=True) | ||||
|  | ||||
|     def find_gamepad(self) -> InputDevice | None: | ||||
|         """ | ||||
|         Находит первый доступный геймпад. | ||||
|         Оптимизирован: предварительная фильтрация по capabilities перед udev-запросами. | ||||
|         """ | ||||
|         try: | ||||
|             devices = [InputDevice(path) for path in list_devices()] | ||||
|  | ||||
|             if not devices: | ||||
|                 return None | ||||
|  | ||||
|             logger.debug(f"Checking {len(devices)} devices for gamepad...") | ||||
|  | ||||
|             for device in devices: | ||||
|                 # Skip ASRock LED controller (vendor ID: 26ce, product ID: 01a2) | ||||
|                 # Skip ASRock LED controller (известная проблема) | ||||
|                 if device.info.vendor == 0x26ce and device.info.product == 0x01a2: | ||||
|                     logger.debug(f"Skipping ASRock LED controller: {device.name}") | ||||
|                     continue | ||||
|                 caps = device.capabilities() | ||||
|                 if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps: | ||||
|                     return device | ||||
|  | ||||
|                 # Предварительная фильтрация: проверяем capabilities | ||||
|                 # Джойстик должен иметь хотя бы оси (ABS) или кнопки (KEY) | ||||
|                 # Это избегает udev-запросов для явно не-джойстиков | ||||
|                 caps = device.capabilities(verbose=False) | ||||
|                 has_abs_axes = ecodes.EV_ABS in caps | ||||
|                 has_buttons = ecodes.EV_KEY in caps | ||||
|  | ||||
|                 if not (has_abs_axes or has_buttons): | ||||
|                     continue | ||||
|  | ||||
|                 # Только для потенциальных джойстиков делаем udev-запрос | ||||
|                 try: | ||||
|                     udev_device = self.Devices.from_device_file( | ||||
|                         self.udev_context, | ||||
|                         device.path | ||||
|                     ) | ||||
|                     is_joystick = udev_device.get('ID_INPUT_JOYSTICK') | ||||
|  | ||||
|                     if is_joystick == '1': | ||||
|                         logger.info(f"Found gamepad: {device.name}") | ||||
|                         return device | ||||
|  | ||||
|                 except Exception as e: | ||||
|                     logger.debug(f"Could not check udev properties for {device.path}: {e}") | ||||
|                     continue | ||||
|  | ||||
|             logger.debug("No gamepad found") | ||||
|             return None | ||||
|  | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error finding gamepad: {e}", exc_info=True) | ||||
|             return None | ||||
|  | ||||
|  | ||||
|     def monitor_gamepad(self) -> None: | ||||
|         try: | ||||
|             if not self.gamepad: | ||||
| @@ -1567,16 +1756,32 @@ class InputManager(QObject): | ||||
|             self.gamepad = None | ||||
|  | ||||
|     def cleanup(self) -> None: | ||||
|         """ | ||||
|         Корректное завершение работы с геймпадом и udev монитором. | ||||
|         """ | ||||
|         try: | ||||
|             # Флаг для остановки udev monitor loop | ||||
|             self.running = False | ||||
|  | ||||
|             # Останавливаем все таймеры | ||||
|             if hasattr(self, 'gamepad_check_timer'): | ||||
|                 self.gamepad_check_timer.stop() | ||||
|             self.dpad_timer.stop() | ||||
|             self.nav_timer.stop() | ||||
|  | ||||
|             # Очистка геймпада | ||||
|             self.stop_rumble() | ||||
|  | ||||
|             if self.gamepad_thread: | ||||
|                 self.gamepad_thread.join() | ||||
|                 self.gamepad_thread.join(timeout=2.0) | ||||
|  | ||||
|             if self.gamepad: | ||||
|                 self.gamepad.close() | ||||
|  | ||||
|             self.gamepad = None | ||||
|             self.gamepad_type = GamepadType.UNKNOWN | ||||
|  | ||||
|             logger.info("Gamepad cleanup completed") | ||||
|  | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error during cleanup: {e}", exc_info=True) | ||||
|   | ||||
| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-10-16 10:43+0500\n" | ||||
| "POT-Creation-Date: 2025-10-16 14:54+0500\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language: de_DE\n" | ||||
| @@ -624,6 +624,12 @@ msgstr "" | ||||
| msgid "Application Fullscreen Mode:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Minimize to tray on close" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Application Close Mode:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Auto Fullscreen on Gamepad connected" | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-10-16 10:43+0500\n" | ||||
| "POT-Creation-Date: 2025-10-16 14:54+0500\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language: es_ES\n" | ||||
| @@ -624,6 +624,12 @@ msgstr "" | ||||
| msgid "Application Fullscreen Mode:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Minimize to tray on close" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Application Close Mode:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Auto Fullscreen on Gamepad connected" | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PortProtonQt 0.1.1\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-10-16 10:43+0500\n" | ||||
| "POT-Creation-Date: 2025-10-16 14:54+0500\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language-Team: LANGUAGE <LL@li.org>\n" | ||||
| @@ -622,6 +622,12 @@ msgstr "" | ||||
| msgid "Application Fullscreen Mode:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Minimize to tray on close" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Application Close Mode:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Auto Fullscreen on Gamepad connected" | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
| @@ -9,8 +9,8 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-10-16 10:43+0500\n" | ||||
| "PO-Revision-Date: 2025-10-16 10:43+0500\n" | ||||
| "POT-Creation-Date: 2025-10-16 14:54+0500\n" | ||||
| "PO-Revision-Date: 2025-10-16 14:54+0500\n" | ||||
| "Last-Translator: \n" | ||||
| "Language: ru_RU\n" | ||||
| "Language-Team: ru_RU <LL@li.org>\n" | ||||
| @@ -633,6 +633,12 @@ msgstr "Запуск приложения в полноэкранном режи | ||||
| msgid "Application Fullscreen Mode:" | ||||
| msgstr "Режим полноэкранного отображения приложения:" | ||||
|  | ||||
| msgid "Minimize to tray on close" | ||||
| msgstr "Сворачивать в трей при закрытии" | ||||
|  | ||||
| msgid "Application Close Mode:" | ||||
| msgstr "Режим закрытия приложения:" | ||||
|  | ||||
| msgid "Auto Fullscreen on Gamepad connected" | ||||
| msgstr "Режим полноэкранного отображения приложения при подключении геймпада" | ||||
|  | ||||
|   | ||||
| @@ -8,7 +8,7 @@ import psutil | ||||
| import re | ||||
|  | ||||
| from portprotonqt.logger import get_logger | ||||
| from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog | ||||
| from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog, ExeSettingsDialog | ||||
| from portprotonqt.game_card import GameCard | ||||
| from portprotonqt.animations import DetailPageAnimations | ||||
| from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel, FlowLayout | ||||
| @@ -29,7 +29,8 @@ from portprotonqt.config_utils import ( | ||||
|     read_display_filter, read_favorites, save_favorites, save_time_config, save_sort_method, | ||||
|     save_display_filter, save_proxy_config, read_proxy_config, read_fullscreen_config, | ||||
|     save_fullscreen_config, read_window_geometry, save_window_geometry, reset_config, | ||||
|     clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config, read_gamepad_type, save_gamepad_type | ||||
|     clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config, read_gamepad_type, save_gamepad_type, read_minimize_to_tray, save_minimize_to_tray, | ||||
|     read_auto_card_size, save_auto_card_size, get_portproton_start_command | ||||
| ) | ||||
| from portprotonqt.localization import _, get_egs_language, read_metadata_translations | ||||
| from portprotonqt.howlongtobeat_api import HowLongToBeat | ||||
| @@ -39,7 +40,7 @@ from portprotonqt.game_library_manager import GameLibraryManager | ||||
| from portprotonqt.virtual_keyboard import VirtualKeyboard | ||||
|  | ||||
| from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, | ||||
|                                QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout, QScrollArea, QScroller) | ||||
|                                QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout, QScrollArea, QScroller, QSlider) | ||||
| from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot, QProcess | ||||
| from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices | ||||
| from typing import cast | ||||
| @@ -63,6 +64,7 @@ class MainWindow(QMainWindow): | ||||
|         self.theme = self.theme_manager.apply_theme(selected_theme) | ||||
|         self.tray_manager = TrayManager(self, app_name, self.current_theme_name) | ||||
|         self.card_width = read_card_size() | ||||
|         self.auto_card_width = read_auto_card_size() | ||||
|         self._last_card_width = self.card_width | ||||
|         self.setWindowTitle(f"{app_name} {version}") | ||||
|         self.setMinimumSize(800, 600) | ||||
| @@ -72,6 +74,7 @@ class MainWindow(QMainWindow): | ||||
|         self.target_exe = None | ||||
|         self.current_running_button = None | ||||
|         self.portproton_location = get_portproton_location() | ||||
|         self.start_sh = get_portproton_start_command() | ||||
|  | ||||
|         self.game_library_manager = GameLibraryManager(self, self.theme, None) | ||||
|  | ||||
| @@ -456,11 +459,11 @@ class MainWindow(QMainWindow): | ||||
|         self.current_install_script = script_name | ||||
|         self.seen_progress = False | ||||
|         self.current_percent = 0.0 | ||||
|         start_sh = os.path.join(self.portproton_location or "", "data", "scripts", "start.sh") if self.portproton_location else "" | ||||
|         if not os.path.exists(start_sh): | ||||
|         start_sh = self.start_sh | ||||
|         if not start_sh: | ||||
|             self.installing = False | ||||
|             return | ||||
|         cmd = [start_sh, "cli", "--autoinstall", script_name] | ||||
|         cmd = start_sh + ["cli", "--autoinstall", script_name] | ||||
|         self.install_process = QProcess(self) | ||||
|         self.install_process.finished.connect(self.on_install_finished) | ||||
|         self.install_process.errorOccurred.connect(self.on_install_error) | ||||
| @@ -1100,8 +1103,7 @@ class MainWindow(QMainWindow): | ||||
|         autoInstallPage = QWidget() | ||||
|         autoInstallPage.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE) | ||||
|         autoInstallLayout = QVBoxLayout(autoInstallPage) | ||||
|         autoInstallLayout.setContentsMargins(20, 0, 20, 0) | ||||
|         autoInstallLayout.setSpacing(0) | ||||
|         autoInstallLayout.setSpacing(15) | ||||
|  | ||||
|         # Верхняя панель с заголовком и поиском | ||||
|         headerWidget = QWidget() | ||||
| @@ -1150,6 +1152,25 @@ class MainWindow(QMainWindow): | ||||
|  | ||||
|         autoInstallLayout.addWidget(self.autoInstallScrollArea) | ||||
|  | ||||
|         # Slider for card size | ||||
|         sliderLayout = QHBoxLayout() | ||||
|         sliderLayout.setSpacing(0) | ||||
|         sliderLayout.setContentsMargins(0, 0, 0, 0) | ||||
|         sliderLayout.addStretch() | ||||
|  | ||||
|         self.auto_size_slider = QSlider(Qt.Orientation.Horizontal) | ||||
|         self.auto_size_slider.setMinimum(200) | ||||
|         self.auto_size_slider.setMaximum(250) | ||||
|         self.auto_size_slider.setValue(self.auto_card_width) | ||||
|         self.auto_size_slider.setTickInterval(10) | ||||
|         self.auto_size_slider.setFixedWidth(150) | ||||
|         self.auto_size_slider.setToolTip(f"{self.auto_card_width} px") | ||||
|         self.auto_size_slider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE) | ||||
|         self.auto_size_slider.sliderReleased.connect(self.on_auto_slider_released) | ||||
|         sliderLayout.addWidget(self.auto_size_slider) | ||||
|  | ||||
|         autoInstallLayout.addLayout(sliderLayout) | ||||
|  | ||||
|         # Хранение карточек | ||||
|         self.autoInstallGameCards = {} | ||||
|         self.allAutoInstallCards = [] | ||||
| @@ -1159,7 +1180,7 @@ class MainWindow(QMainWindow): | ||||
|             if exe_name in self.autoInstallGameCards and local_path: | ||||
|                 card = self.autoInstallGameCards[exe_name] | ||||
|                 card.cover_path = local_path | ||||
|                 load_pixmap_async(local_path, self.card_width, int(self.card_width * 1.5), card.on_cover_loaded) | ||||
|                 load_pixmap_async(local_path, self.auto_card_width, int(self.auto_card_width * 1.5), card.on_cover_loaded) | ||||
|  | ||||
|         # Загрузка игр | ||||
|         def on_autoinstall_games_loaded(games: list[tuple]): | ||||
| @@ -1195,7 +1216,7 @@ class MainWindow(QMainWindow): | ||||
|                     None, None, None, game_source, | ||||
|                     select_callback=select_callback, | ||||
|                     theme=self.theme, | ||||
|                     card_width=self.card_width, | ||||
|                     card_width=self.auto_card_width, | ||||
|                     parent=self.autoInstallContainer, | ||||
|                 ) | ||||
|  | ||||
| @@ -1233,10 +1254,30 @@ class MainWindow(QMainWindow): | ||||
|         # Показываем прогресс | ||||
|         self.autoInstallProgress.setVisible(True) | ||||
|         self.autoInstallProgress.setRange(0, 0) | ||||
|         self.portproton_api.get_autoinstall_games_async(on_autoinstall_games_loaded) | ||||
|  | ||||
|         # Store the thread to prevent premature destruction | ||||
|         self.autoInstallLoadThread = self.portproton_api.start_autoinstall_games_load(on_autoinstall_games_loaded) | ||||
|  | ||||
|         # Optional: Clean up thread when finished (prevents leak) | ||||
|         if self.autoInstallLoadThread: | ||||
|             def on_thread_finished(): | ||||
|                 self.autoInstallLoadThread = None  # Release reference | ||||
|             self.autoInstallLoadThread.finished.connect(on_thread_finished) | ||||
|  | ||||
|         self.stackedWidget.addWidget(autoInstallPage) | ||||
|  | ||||
|     def on_auto_slider_released(self): | ||||
|         """Handles auto-install slider release to update card size.""" | ||||
|         if hasattr(self, 'auto_size_slider') and self.auto_size_slider: | ||||
|             self.auto_card_width = self.auto_size_slider.value() | ||||
|             self.auto_size_slider.setToolTip(f"{self.auto_card_width} px") | ||||
|             save_auto_card_size(self.auto_card_width) | ||||
|             for card in self.allAutoInstallCards: | ||||
|                 card.update_card_size(self.auto_card_width) | ||||
|             self.autoInstallContainerLayout.invalidate() | ||||
|             self.autoInstallContainer.updateGeometry() | ||||
|             self.autoInstallScrollArea.updateGeometry() | ||||
|  | ||||
|     def filterAutoInstallGames(self): | ||||
|         """Filter auto install game cards based on search text.""" | ||||
|         search_text = self.autoInstallSearchLineEdit.text().lower().strip() | ||||
| @@ -1384,12 +1425,10 @@ class MainWindow(QMainWindow): | ||||
|         prefix = self.prefixCombo.currentText() | ||||
|         if not wine or not prefix: | ||||
|             return | ||||
|         if not self.portproton_location: | ||||
|         if not self.portproton_location or not self.start_sh: | ||||
|             return | ||||
|         start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh") | ||||
|         if not os.path.exists(start_sh): | ||||
|             return | ||||
|         cmd = [start_sh, "cli", cli_arg, wine, prefix] | ||||
|         start_sh = self.start_sh | ||||
|         cmd = start_sh + ["cli", cli_arg, wine, prefix] | ||||
|  | ||||
|         # Показываем прогресс-бар перед запуском | ||||
|         self.wine_progress_bar.setVisible(True) | ||||
| @@ -1468,12 +1507,13 @@ class MainWindow(QMainWindow): | ||||
|         QMessageBox.warning(self, _("Error"), f"Failed to launch tool: {error}") | ||||
|  | ||||
|     def clear_prefix(self): | ||||
|         """Очистка префикса (позже удалить).""" | ||||
|         """Очищает префикс""" | ||||
|         selected_prefix = self.prefixCombo.currentText() | ||||
|         selected_wine = self.wineCombo.currentText() | ||||
|  | ||||
|         if not selected_prefix or not selected_wine: | ||||
|             return | ||||
|         if not self.portproton_location: | ||||
|         if not self.portproton_location or not self.start_sh: | ||||
|             return | ||||
|  | ||||
|         reply = QMessageBox.question( | ||||
| @@ -1486,98 +1526,35 @@ class MainWindow(QMainWindow): | ||||
|         if reply != QMessageBox.StandardButton.Yes: | ||||
|             return | ||||
|  | ||||
|         prefix_dir = os.path.join(self.portproton_location, "data", "prefixes", selected_prefix) | ||||
|         if not os.path.exists(prefix_dir): | ||||
|         start_sh = self.start_sh | ||||
|  | ||||
|         self.wine_progress_bar.setVisible(True) | ||||
|         self.update_status_message.emit(_("Clearing prefix..."), 0) | ||||
|  | ||||
|         self.clear_process = QProcess(self) | ||||
|         self.clear_process.finished.connect(lambda exitCode, exitStatus: self._on_clear_prefix_finished(exitCode)) | ||||
|         self.clear_process.errorOccurred.connect(lambda error: self._on_clear_prefix_error(error)) | ||||
|         cmd = start_sh + ["cli", "--clear_pfx", selected_wine, selected_prefix] | ||||
|         self.clear_process.start(cmd[0], cmd[1:]) | ||||
|  | ||||
|         if not self.clear_process.waitForStarted(5000): | ||||
|             self.wine_progress_bar.setVisible(False) | ||||
|             self.update_status_message.emit("", 0) | ||||
|             QMessageBox.warning(self, _("Error"), _("Failed to start prefix clear process.")) | ||||
|             return | ||||
|  | ||||
|         success = True | ||||
|         errors = [] | ||||
|  | ||||
|         # Удаление файлов | ||||
|         files_to_remove = [ | ||||
|             os.path.join(prefix_dir, "*.dot*"), | ||||
|             os.path.join(prefix_dir, "*.prog*"), | ||||
|             os.path.join(prefix_dir, ".wine_ver"), | ||||
|             os.path.join(prefix_dir, "system.reg"), | ||||
|             os.path.join(prefix_dir, "user.reg"), | ||||
|             os.path.join(prefix_dir, "userdef.reg"), | ||||
|             os.path.join(prefix_dir, "winetricks.log"), | ||||
|             os.path.join(prefix_dir, ".update-timestamp"), | ||||
|             os.path.join(prefix_dir, "drive_c", ".windows-serial"), | ||||
|         ] | ||||
|  | ||||
|         import glob | ||||
|         for pattern in files_to_remove: | ||||
|             if "*" in pattern:  # Глобальный паттерн | ||||
|                 matches = glob.glob(pattern) | ||||
|                 for file_path in matches: | ||||
|                     try: | ||||
|                         if os.path.exists(file_path): | ||||
|                             os.remove(file_path) | ||||
|                     except Exception as e: | ||||
|                         success = False | ||||
|                         errors.append(str(e)) | ||||
|             else:  # Конкретный файл | ||||
|                 try: | ||||
|                     if os.path.exists(pattern): | ||||
|                         os.remove(pattern) | ||||
|                 except Exception as e: | ||||
|                     success = False | ||||
|                     errors.append(str(e)) | ||||
|  | ||||
|         # Удаление директорий | ||||
|         dirs_to_remove = [ | ||||
|             os.path.join(prefix_dir, "drive_c", "windows"), | ||||
|             os.path.join(prefix_dir, "drive_c", "ProgramData", "Setup"), | ||||
|             os.path.join(prefix_dir, "drive_c", "ProgramData", "Windows"), | ||||
|             os.path.join(prefix_dir, "drive_c", "ProgramData", "WindowsTask"), | ||||
|             os.path.join(prefix_dir, "drive_c", "ProgramData", "Package Cache"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Application Data", "Microsoft"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Application Data", "Temp"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Temporary Internet Files"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "Public", "Application Data", "Microsoft"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "Public", "Application Data", "wine_gecko"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "Public", "Temp"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Application Data", "Microsoft"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Application Data", "Temp"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Temporary Internet Files"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Application Data", "Microsoft"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Application Data", "wine_gecko"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Temp"), | ||||
|             os.path.join(prefix_dir, "drive_c", "Program Files", "Internet Explorer"), | ||||
|             os.path.join(prefix_dir, "drive_c", "Program Files", "Windows Media Player"), | ||||
|             os.path.join(prefix_dir, "drive_c", "Program Files", "Windows NT"), | ||||
|             os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Internet Explorer"), | ||||
|             os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Windows Media Player"), | ||||
|             os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Windows NT"), | ||||
|         ] | ||||
|  | ||||
|         import shutil | ||||
|         for dir_path in dirs_to_remove: | ||||
|             try: | ||||
|                 if os.path.exists(dir_path): | ||||
|                     shutil.rmtree(dir_path) | ||||
|             except Exception as e: | ||||
|                 success = False | ||||
|                 errors.append(str(e)) | ||||
|  | ||||
|         tmp_path = os.path.join(self.portproton_location, "data", "tmp") | ||||
|         if os.path.exists(tmp_path): | ||||
|             import glob | ||||
|             bin_files = glob.glob(os.path.join(tmp_path, "*.bin")) | ||||
|             foz_files = glob.glob(os.path.join(tmp_path, "*.foz")) | ||||
|             for file_path in bin_files + foz_files: | ||||
|                 try: | ||||
|                     os.remove(file_path) | ||||
|                 except Exception as e: | ||||
|                     success = False | ||||
|                     errors.append(str(e)) | ||||
|  | ||||
|         if success: | ||||
|             QMessageBox.information(self, _("Success"), _("Prefix '{}' cleared successfully.").format(selected_prefix)) | ||||
|     def _on_clear_prefix_finished(self, exitCode): | ||||
|         self.wine_progress_bar.setVisible(False) | ||||
|         self.update_status_message.emit("", 0) | ||||
|         if exitCode == 0: | ||||
|             QMessageBox.information(self, _("Success"), _("Prefix cleared successfully.")) | ||||
|         else: | ||||
|             error_msg = _("Prefix '{}' cleared with errors:\n{}").format(selected_prefix, "\n".join(errors[:5])) | ||||
|             QMessageBox.warning(self, _("Warning"), error_msg) | ||||
|             QMessageBox.warning(self, _("Error"), _("Prefix clear failed with exit code {}.").format(exitCode)) | ||||
|  | ||||
|     def _on_clear_prefix_error(self, error): | ||||
|         self.wine_progress_bar.setVisible(False) | ||||
|         self.update_status_message.emit("", 0) | ||||
|         QMessageBox.warning(self, _("Error"), _("Failed to run clear prefix command: {}").format(error)) | ||||
|  | ||||
|     def create_prefix_backup(self): | ||||
|         selected_prefix = self.prefixCombo.currentText() | ||||
| @@ -1589,14 +1566,12 @@ class MainWindow(QMainWindow): | ||||
|  | ||||
|     def _perform_backup(self, backup_dir, prefix_name): | ||||
|         os.makedirs(backup_dir, exist_ok=True) | ||||
|         if not self.portproton_location: | ||||
|             return | ||||
|         start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh") | ||||
|         if not os.path.exists(start_sh): | ||||
|         if not self.portproton_location or not self.start_sh: | ||||
|             return | ||||
|         start_sh = self.start_sh | ||||
|         self.backup_process = QProcess(self) | ||||
|         self.backup_process.finished.connect(lambda exitCode, exitStatus: self._on_backup_finished(exitCode)) | ||||
|         cmd = [start_sh, "--backup-prefix", prefix_name, backup_dir] | ||||
|         cmd = start_sh + ["--backup-prefix", prefix_name, backup_dir] | ||||
|         self.backup_process.start(cmd[0], cmd[1:]) | ||||
|         if not self.backup_process.waitForStarted(): | ||||
|             QMessageBox.warning(self, _("Error"), _("Failed to start backup process.")) | ||||
| @@ -1609,14 +1584,12 @@ class MainWindow(QMainWindow): | ||||
|     def _perform_restore(self, file_path): | ||||
|         if not file_path or not os.path.exists(file_path): | ||||
|             return | ||||
|         if not self.portproton_location: | ||||
|             return | ||||
|         start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh") | ||||
|         if not os.path.exists(start_sh): | ||||
|         if not self.portproton_location or not self.start_sh: | ||||
|             return | ||||
|         start_sh = self.start_sh | ||||
|         self.restore_process = QProcess(self) | ||||
|         self.restore_process.finished.connect(lambda exitCode, exitStatus: self._on_restore_finished(exitCode)) | ||||
|         cmd = [start_sh, "--restore-prefix", file_path] | ||||
|         cmd = start_sh + ["--restore-prefix", file_path] | ||||
|         self.restore_process.start(cmd[0], cmd[1:]) | ||||
|         if not self.restore_process.waitForStarted(): | ||||
|             QMessageBox.warning(self, _("Error"), _("Failed to start restore process.")) | ||||
| @@ -1860,7 +1833,19 @@ class MainWindow(QMainWindow): | ||||
|         self.fullscreenCheckBox.setChecked(current_fullscreen) | ||||
|         formLayout.addRow(self.fullscreenTitle, self.fullscreenCheckBox) | ||||
|  | ||||
|         # 7. Automatic fullscreen on gamepad connection | ||||
|         # 7. Minimize to tray setting | ||||
|         self.minimizeToTrayCheckBox = QCheckBox(_("Minimize to tray on close")) | ||||
|         self.minimizeToTrayCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE) | ||||
|         self.minimizeToTrayCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus) | ||||
|         self.minimizeToTrayTitle = QLabel(_("Application Close Mode:")) | ||||
|         self.minimizeToTrayTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) | ||||
|         self.minimizeToTrayTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) | ||||
|         current_minimize_to_tray = read_minimize_to_tray() | ||||
|         self.minimizeToTrayCheckBox.setChecked(current_minimize_to_tray) | ||||
|         self.minimizeToTrayCheckBox.toggled.connect(lambda checked: save_minimize_to_tray(checked)) | ||||
|         formLayout.addRow(self.minimizeToTrayTitle, self.minimizeToTrayCheckBox) | ||||
|  | ||||
|         # 8. Automatic fullscreen on gamepad connection | ||||
|         self.autoFullscreenGamepadCheckBox = QCheckBox(_("Auto Fullscreen on Gamepad connected")) | ||||
|         self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE) | ||||
|         self.autoFullscreenGamepadCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus) | ||||
| @@ -1872,7 +1857,7 @@ class MainWindow(QMainWindow): | ||||
|         self.autoFullscreenGamepadCheckBox.setChecked(current_auto_fullscreen) | ||||
|         formLayout.addRow(self.autoFullscreenGamepadTitle, self.autoFullscreenGamepadCheckBox) | ||||
|  | ||||
|         # 8. Gamepad haptic feedback config | ||||
|         # 9. Gamepad haptic feedback config | ||||
|         self.gamepadRumbleCheckBox = QCheckBox(_("Gamepad haptic feedback")) | ||||
|         self.gamepadRumbleCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus) | ||||
|         self.gamepadRumbleCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE) | ||||
| @@ -2274,6 +2259,14 @@ class MainWindow(QMainWindow): | ||||
|     def darkenColor(self, color, factor=200): | ||||
|         return color.darker(factor) | ||||
|  | ||||
|     def open_exe_settings(self, exe_path): | ||||
|         """Open the ExeSettingsDialog for the given executable.""" | ||||
|         if not os.path.exists(exe_path): | ||||
|             QMessageBox.warning(self, _("Error"), _("Executable not found: {0}").format(exe_path)) | ||||
|             return | ||||
|         dialog = ExeSettingsDialog(self, self.theme, exe_path) | ||||
|         dialog.exec() | ||||
|  | ||||
|     def openGameDetailPage(self, name, description, cover_path=None, appid="", exec_line="", controller_support="", last_launch="", formatted_playtime="", protondb_tier="", game_source="", anticheat_status=""): | ||||
|         detailPage = QWidget() | ||||
|         self._animations = {} | ||||
| @@ -2576,8 +2569,6 @@ class MainWindow(QMainWindow): | ||||
|  | ||||
|                 clear_layout(hltbLayout) | ||||
|  | ||||
|  | ||||
|  | ||||
|                 has_data = False | ||||
|  | ||||
|                 if main_story_time is not None: | ||||
| @@ -2661,6 +2652,14 @@ class MainWindow(QMainWindow): | ||||
|         playButton.clicked.connect(lambda: self.toggleGame(exec_line, playButton)) | ||||
|         detailsLayout.addWidget(playButton, alignment=Qt.AlignmentFlag.AlignLeft) | ||||
|  | ||||
|         # Settings button | ||||
|         settings_icon = self.theme_manager.get_icon("settings") | ||||
|         settings_button = AutoSizeButton(_("Settings"), icon=settings_icon) | ||||
|         settings_button.setFixedSize(120, 40) | ||||
|         settings_button.setStyleSheet(self.theme.PLAY_BUTTON_STYLE) | ||||
|         settings_button.clicked.connect(lambda: self.open_exe_settings(file_to_check)) | ||||
|         detailsLayout.addWidget(settings_button, alignment=Qt.AlignmentFlag.AlignLeft) | ||||
|  | ||||
|         contentFrameLayout.addWidget(detailsWidget) | ||||
|         mainLayout.addStretch() | ||||
|  | ||||
| @@ -2883,10 +2882,7 @@ class MainWindow(QMainWindow): | ||||
|                 env_vars = os.environ.copy() | ||||
|                 env_vars['LEGENDARY_CONFIG_PATH'] = self.legendary_config_path | ||||
|  | ||||
|                 wrapper = "flatpak run ru.linux_gaming.PortProton" | ||||
|                 if self.portproton_location is not None and ".var" not in self.portproton_location: | ||||
|                     start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh") | ||||
|                     wrapper = start_sh | ||||
|                 wrapper = self.start_sh or "" | ||||
|  | ||||
|                 cmd = [wrapper, game_exe] | ||||
|  | ||||
| @@ -2980,13 +2976,6 @@ class MainWindow(QMainWindow): | ||||
|             exe_name = os.path.splitext(current_exe)[0] | ||||
|             env_vars = os.environ.copy() | ||||
|  | ||||
|             if entry_exec_split[0] == "env" and len(entry_exec_split) > 1 and 'data/scripts/start.sh' in entry_exec_split[1]: | ||||
|                 env_vars['START_FROM_STEAM'] = '1' | ||||
|                 env_vars['PROCESS_LOG'] = '1' | ||||
|             elif entry_exec_split[0] == "flatpak": | ||||
|                 env_vars['START_FROM_STEAM'] = '1' | ||||
|                 env_vars['PROCESS_LOG'] = '1' | ||||
|  | ||||
|             # Запускаем игру | ||||
|             try: | ||||
|                 process = subprocess.Popen(entry_exec_split, env=env_vars, shell=False, preexec_fn=os.setsid) | ||||
| @@ -3008,57 +2997,77 @@ class MainWindow(QMainWindow): | ||||
|                 logger.error(f"Failed to launch game {exe_name}: {e}") | ||||
|                 QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e))) | ||||
|  | ||||
|  | ||||
|     def closeEvent(self, event): | ||||
|         """Обработчик закрытия окна: сворачивает приложение в трей, если не требуется принудительный выход.""" | ||||
|         if hasattr(self, 'is_exiting') and self.is_exiting: | ||||
|             # Принудительное закрытие: завершаем процессы и приложение | ||||
|             for proc in self.game_processes: | ||||
|                 try: | ||||
|                     parent = psutil.Process(proc.pid) | ||||
|                     children = parent.children(recursive=True) | ||||
|                     for child in children: | ||||
|                         try: | ||||
|                             logger.debug(f"Terminating child process {child.pid}") | ||||
|                             child.terminate() | ||||
|                         except psutil.NoSuchProcess: | ||||
|                             logger.debug(f"Child process {child.pid} already terminated") | ||||
|                     psutil.wait_procs(children, timeout=5) | ||||
|                     for child in children: | ||||
|                         if child.is_running(): | ||||
|                             logger.debug(f"Killing child process {child.pid}") | ||||
|                             child.kill() | ||||
|                     logger.debug(f"Terminating process group {proc.pid}") | ||||
|                     os.killpg(os.getpgid(proc.pid), signal.SIGTERM) | ||||
|                 except (psutil.NoSuchProcess, ProcessLookupError) as e: | ||||
|                     logger.debug(f"Process {proc.pid} already terminated: {e}") | ||||
|         """Обработчик закрытия окна: проверяет настройку minimize_to_tray. | ||||
|         Если True — сворачиваем в трей (по умолчанию). Иначе — полностью закрываем. | ||||
|         """ | ||||
|         minimize_to_tray = read_minimize_to_tray() | ||||
|  | ||||
|             self.game_processes = []  # Очищаем список процессов | ||||
|  | ||||
|             # Очищаем таймеры | ||||
|             if hasattr(self, 'games_load_timer') and self.games_load_timer is not None and self.games_load_timer.isActive(): | ||||
|                 self.games_load_timer.stop() | ||||
|             if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer is not None and self.settingsDebounceTimer.isActive(): | ||||
|                 self.settingsDebounceTimer.stop() | ||||
|             if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer is not None and self.searchDebounceTimer.isActive(): | ||||
|                 self.searchDebounceTimer.stop() | ||||
|             if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None and self.checkProcessTimer.isActive(): | ||||
|                 self.checkProcessTimer.stop() | ||||
|                 self.checkProcessTimer.deleteLater() | ||||
|                 self.checkProcessTimer = None | ||||
|             if hasattr(self, 'wine_monitor_timer') and self.wine_monitor_timer is not None: | ||||
|                 self.wine_monitor_timer.stop() | ||||
|                 self.wine_monitor_timer.deleteLater() | ||||
|                 self.wine_monitor_timer = None | ||||
|  | ||||
|             # Сохраняем настройки окна | ||||
|             if not read_fullscreen_config(): | ||||
|                 logger.debug(f"Saving window geometry: {self.width()}x{self.height()}") | ||||
|                 save_window_geometry(self.width(), self.height()) | ||||
|             save_card_size(self.card_width) | ||||
|  | ||||
|             event.accept() | ||||
|         else: | ||||
|             # Сворачиваем в трей вместо закрытия | ||||
|             self.hide() | ||||
|         if minimize_to_tray: | ||||
|             # Просто сворачиваем в трей | ||||
|             event.ignore() | ||||
|             self.hide() | ||||
|             return | ||||
|  | ||||
|         # Полное закрытие приложения | ||||
|         self.is_exiting = True | ||||
|         event.accept() | ||||
|  | ||||
|         # Скрываем и удаляем иконку трея | ||||
|         if hasattr(self, "tray_manager") and self.tray_manager.tray_icon: | ||||
|             self.tray_manager.tray_icon.hide() | ||||
|             self.tray_manager.tray_icon.deleteLater() | ||||
|  | ||||
|         # Сохраняем размеры карточек | ||||
|         save_card_size(self.card_width) | ||||
|         save_auto_card_size(self.auto_card_width) | ||||
|  | ||||
|         # Сохраняем размеры окна (если не в полноэкранном режиме) | ||||
|         if not read_fullscreen_config(): | ||||
|             logger.debug(f"Saving window geometry: {self.width()}x{self.height()}") | ||||
|             save_window_geometry(self.width(), self.height()) | ||||
|  | ||||
|         # Завершаем все игровые процессы | ||||
|         for proc in getattr(self, "game_processes", []): | ||||
|             try: | ||||
|                 parent = psutil.Process(proc.pid) | ||||
|                 children = parent.children(recursive=True) | ||||
|                 for child in children: | ||||
|                     try: | ||||
|                         logger.debug(f"Terminating child process {child.pid}") | ||||
|                         child.terminate() | ||||
|                     except psutil.NoSuchProcess: | ||||
|                         logger.debug(f"Child process {child.pid} already terminated") | ||||
|  | ||||
|                 psutil.wait_procs(children, timeout=5) | ||||
|                 for child in children: | ||||
|                     if child.is_running(): | ||||
|                         logger.debug(f"Killing child process {child.pid}") | ||||
|                         child.kill() | ||||
|  | ||||
|                 logger.debug(f"Terminating process group {proc.pid}") | ||||
|                 os.killpg(os.getpgid(proc.pid), signal.SIGTERM) | ||||
|  | ||||
|             except (psutil.NoSuchProcess, ProcessLookupError) as e: | ||||
|                 logger.debug(f"Process {getattr(proc, 'pid', '?')} already terminated: {e}") | ||||
|             except Exception as e: | ||||
|                 logger.warning(f"Failed to terminate process {getattr(proc, 'pid', '?')}: {e}") | ||||
|  | ||||
|         self.game_processes = [] | ||||
|  | ||||
|         # Универсальная остановка и удаление таймеров | ||||
|         timers = [ | ||||
|             "games_load_timer", | ||||
|             "settingsDebounceTimer", | ||||
|             "searchDebounceTimer", | ||||
|             "checkProcessTimer", | ||||
|             "wine_monitor_timer", | ||||
|         ] | ||||
|  | ||||
|         for tname in timers: | ||||
|             timer = getattr(self, tname, None) | ||||
|             if timer and timer.isActive(): | ||||
|                 timer.stop() | ||||
|             if timer: | ||||
|                 timer.deleteLater() | ||||
|                 setattr(self, tname, None) | ||||
|   | ||||
| @@ -6,13 +6,16 @@ import urllib.parse | ||||
| import time | ||||
| import glob | ||||
| import re | ||||
| import hashlib | ||||
| from collections.abc import Callable | ||||
| from PySide6.QtCore import QThread, Signal | ||||
| from portprotonqt.downloader import Downloader | ||||
| from portprotonqt.logger import get_logger | ||||
| from portprotonqt.config_utils import get_portproton_location | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
| CACHE_DURATION = 30 * 24 * 60 * 60  # 30 days in seconds | ||||
| AUTOINSTALL_CACHE_DURATION = 3600  # 1 hour for autoinstall cache | ||||
|  | ||||
| def normalize_name(s): | ||||
|     """ | ||||
| @@ -59,6 +62,7 @@ class PortProtonAPI: | ||||
|         self.repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | ||||
|         self.builtin_custom_folder = os.path.join(self.repo_root, "custom_data") | ||||
|         self._topics_data = None | ||||
|         self._autoinstall_cache = None  # New: In-memory cache | ||||
|  | ||||
|     def _get_game_dir(self, exe_name: str) -> str: | ||||
|         game_dir = os.path.join(self.custom_data_dir, exe_name) | ||||
| @@ -231,67 +235,139 @@ class PortProtonAPI: | ||||
|             logger.error(f"Failed to parse {file_path}: {e}") | ||||
|             return None, None | ||||
|  | ||||
|     def get_autoinstall_games_async(self, callback: Callable[[list[tuple]], None]) -> None: | ||||
|         """Load auto-install games with user/builtin covers (no async download here).""" | ||||
|         games = [] | ||||
|         auto_dir = os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall") if self.portproton_location else "" | ||||
|     def _compute_scripts_signature(self, auto_dir: str) -> str: | ||||
|         """Compute a hash-based signature of the autoinstall scripts to detect changes.""" | ||||
|         if not os.path.exists(auto_dir): | ||||
|             callback(games) | ||||
|             return | ||||
|  | ||||
|             return "" | ||||
|         scripts = sorted(glob.glob(os.path.join(auto_dir, "*"))) | ||||
|         if not scripts: | ||||
|             callback(games) | ||||
|             return | ||||
|         # Simple hash: concatenate sorted filenames and hash | ||||
|         filenames_str = "".join(sorted([os.path.basename(s) for s in scripts])) | ||||
|         return hashlib.md5(filenames_str.encode()).hexdigest() | ||||
|  | ||||
|         xdg_data_home = os.getenv("XDG_DATA_HOME", | ||||
|                                 os.path.join(os.path.expanduser("~"), ".local", "share")) | ||||
|         base_autoinstall_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall") | ||||
|         os.makedirs(base_autoinstall_dir, exist_ok=True) | ||||
|     def _load_autoinstall_cache(self): | ||||
|         """Load cached autoinstall games if fresh and scripts unchanged.""" | ||||
|         if self._autoinstall_cache is not None: | ||||
|             return self._autoinstall_cache | ||||
|         cache_dir = get_cache_dir() | ||||
|         cache_file = os.path.join(cache_dir, "autoinstall_games_cache.json") | ||||
|         if os.path.exists(cache_file): | ||||
|             try: | ||||
|                 mod_time = os.path.getmtime(cache_file) | ||||
|                 if time.time() - mod_time < AUTOINSTALL_CACHE_DURATION: | ||||
|                     with open(cache_file, "rb") as f: | ||||
|                         data = orjson.loads(f.read()) | ||||
|                         # Check signature | ||||
|                         cached_signature = data.get("scripts_signature", "") | ||||
|                         current_signature = self._compute_scripts_signature( | ||||
|                             os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall") | ||||
|                         ) | ||||
|                         if cached_signature != current_signature: | ||||
|                             logger.info("Scripts signature mismatch; invalidating cache") | ||||
|                             return None | ||||
|                         self._autoinstall_cache = data["games"] | ||||
|                         logger.info(f"Loaded {len(self._autoinstall_cache)} cached autoinstall games") | ||||
|                         return self._autoinstall_cache | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Failed to load autoinstall cache: {e}") | ||||
|         return None | ||||
|  | ||||
|         for script_path in scripts: | ||||
|             display_name, exe_name = self.parse_autoinstall_script(script_path) | ||||
|             script_name = os.path.splitext(os.path.basename(script_path))[0] | ||||
|     def _save_autoinstall_cache(self, games): | ||||
|         """Save parsed autoinstall games to cache with scripts signature.""" | ||||
|         try: | ||||
|             cache_dir = get_cache_dir() | ||||
|             cache_file = os.path.join(cache_dir, "autoinstall_games_cache.json") | ||||
|             auto_dir = os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall") | ||||
|             scripts_signature = self._compute_scripts_signature(auto_dir) | ||||
|             data = {"games": games, "scripts_signature": scripts_signature, "timestamp": time.time()} | ||||
|             with open(cache_file, "wb") as f: | ||||
|                 f.write(orjson.dumps(data)) | ||||
|             logger.debug(f"Saved {len(games)} autoinstall games to cache with signature {scripts_signature}") | ||||
|         except Exception as e: | ||||
|             logger.error(f"Failed to save autoinstall cache: {e}") | ||||
|  | ||||
|             if not (display_name and exe_name): | ||||
|                 continue | ||||
|     def start_autoinstall_games_load(self, callback: Callable[[list[tuple]], None]) -> QThread | None: | ||||
|         """Start loading auto-install games in a background thread. Returns the thread for management.""" | ||||
|         # Check cache first (sync, fast) | ||||
|         cached_games = self._load_autoinstall_cache() | ||||
|         if cached_games is not None: | ||||
|             # Emit via callback immediately if cached | ||||
|             QThread.msleep(0)  # Yield to Qt event loop | ||||
|             callback(cached_games) | ||||
|             return None  # No thread needed | ||||
|  | ||||
|             exe_name = os.path.splitext(exe_name)[0]  # Без .exe | ||||
|             user_game_folder = os.path.join(base_autoinstall_dir, exe_name) | ||||
|             os.makedirs(user_game_folder, exist_ok=True) | ||||
|         # No cache: Start background thread | ||||
|         class AutoinstallWorker(QThread): | ||||
|             finished = Signal(list) | ||||
|             api: "PortProtonAPI" | ||||
|             portproton_location: str | None | ||||
|  | ||||
|             # Поиск обложки | ||||
|             cover_path = "" | ||||
|             user_files = set(os.listdir(user_game_folder)) if os.path.exists(user_game_folder) else set() | ||||
|             for ext in [".jpg", ".png", ".jpeg", ".bmp"]: | ||||
|                 candidate = f"cover{ext}" | ||||
|                 if candidate in user_files: | ||||
|                     cover_path = os.path.join(user_game_folder, candidate) | ||||
|                     break | ||||
|             def run(self): | ||||
|                 games = [] | ||||
|                 auto_dir = os.path.join( | ||||
|                     self.portproton_location or "", "data", "scripts", "pw_autoinstall" | ||||
|                 ) if self.portproton_location else "" | ||||
|                 if not os.path.exists(auto_dir): | ||||
|                     self.finished.emit(games) | ||||
|                     return | ||||
|  | ||||
|             if not cover_path: | ||||
|                 logger.debug(f"No local cover found for autoinstall {exe_name}") | ||||
|                 scripts = sorted(glob.glob(os.path.join(auto_dir, "*"))) | ||||
|                 if not scripts: | ||||
|                     self.finished.emit(games) | ||||
|                     return | ||||
|  | ||||
|             # Формируем кортеж игры (добавлен exe_name в конец) | ||||
|             game_tuple = ( | ||||
|                 display_name,  # name | ||||
|                 "",  # description | ||||
|                 cover_path,  # cover | ||||
|                 "",  # appid | ||||
|                 f"autoinstall:{script_name}",  # exec_line | ||||
|                 "",  # controller_support | ||||
|                 "Never",  # last_launch | ||||
|                 "0h 0m",  # formatted_playtime | ||||
|                 "",  # protondb_tier | ||||
|                 "",  # anticheat_status | ||||
|                 0,  # last_played | ||||
|                 0,  # playtime_seconds | ||||
|                 "autoinstall",  # game_source | ||||
|                 exe_name  # exe_name | ||||
|             ) | ||||
|             games.append(game_tuple) | ||||
|                 xdg_data_home = os.getenv( | ||||
|                     "XDG_DATA_HOME", | ||||
|                     os.path.join(os.path.expanduser("~"), ".local", "share"), | ||||
|                 ) | ||||
|                 base_autoinstall_dir = os.path.join( | ||||
|                     xdg_data_home, "PortProtonQt", "custom_data", "autoinstall" | ||||
|                 ) | ||||
|                 os.makedirs(base_autoinstall_dir, exist_ok=True) | ||||
|  | ||||
|         callback(games) | ||||
|                 for script_path in scripts: | ||||
|                     display_name, exe_name = self.api.parse_autoinstall_script(script_path) | ||||
|                     script_name = os.path.splitext(os.path.basename(script_path))[0] | ||||
|  | ||||
|                     if not (display_name and exe_name): | ||||
|                         continue | ||||
|  | ||||
|                     exe_name = os.path.splitext(exe_name)[0] | ||||
|                     user_game_folder = os.path.join(base_autoinstall_dir, exe_name) | ||||
|                     os.makedirs(user_game_folder, exist_ok=True) | ||||
|  | ||||
|                     # Find cover | ||||
|                     cover_path = "" | ||||
|                     user_files = ( | ||||
|                         set(os.listdir(user_game_folder)) | ||||
|                         if os.path.exists(user_game_folder) | ||||
|                         else set() | ||||
|                     ) | ||||
|                     for ext in [".jpg", ".png", ".jpeg", ".bmp"]: | ||||
|                         candidate = f"cover{ext}" | ||||
|                         if candidate in user_files: | ||||
|                             cover_path = os.path.join(user_game_folder, candidate) | ||||
|                             break | ||||
|  | ||||
|                     if not cover_path: | ||||
|                         logger.debug(f"No local cover found for autoinstall {exe_name}") | ||||
|  | ||||
|                     game_tuple = ( | ||||
|                         display_name, "", cover_path, "", f"autoinstall:{script_name}", | ||||
|                         "", "Never", "0h 0m", "", "", 0, 0, "autoinstall", exe_name | ||||
|                     ) | ||||
|                     games.append(game_tuple) | ||||
|  | ||||
|                 self.api._save_autoinstall_cache(games) | ||||
|                 self.api._autoinstall_cache = games | ||||
|                 self.finished.emit(games) | ||||
|  | ||||
|         worker = AutoinstallWorker() | ||||
|         worker.api = self | ||||
|         worker.portproton_location = self.portproton_location | ||||
|         worker.finished.connect(lambda games: callback(games)) | ||||
|         worker.start() | ||||
|         logger.info("Started background load of autoinstall games") | ||||
|         return worker | ||||
|  | ||||
|     def _load_topics_data(self): | ||||
|         """Load and cache linux_gaming_topics_min.json from the archive.""" | ||||
|   | ||||
| @@ -13,7 +13,7 @@ from portprotonqt.logger import get_logger | ||||
| from portprotonqt.localization import get_steam_language | ||||
| from portprotonqt.downloader import Downloader | ||||
| from portprotonqt.dialogs import generate_thumbnail | ||||
| from portprotonqt.config_utils import get_portproton_location | ||||
| from portprotonqt.config_utils import get_portproton_location, get_portproton_start_command | ||||
| from collections.abc import Callable | ||||
| import re | ||||
| import shutil | ||||
| @@ -23,6 +23,7 @@ import requests | ||||
| import random | ||||
| import base64 | ||||
| import glob | ||||
| import urllib.parse | ||||
|  | ||||
| downloader = Downloader() | ||||
| logger = get_logger(__name__) | ||||
| @@ -411,6 +412,39 @@ def save_app_details(app_id, data): | ||||
|     with open(cache_file, "wb") as f: | ||||
|         f.write(orjson.dumps(data)) | ||||
|  | ||||
| def fetch_sgdb_cover(game_name: str) -> str: | ||||
|     """ | ||||
|     Fetch a cover image URL from steamgrid.usebottles.com for the given game. | ||||
|     The API returns a single string (quoted URL). | ||||
|     """ | ||||
|     try: | ||||
|         encoded = urllib.parse.quote(game_name) | ||||
|         url = f"https://steamgrid.usebottles.com/api/search/{encoded}" | ||||
|         resp = requests.get(url, timeout=5) | ||||
|         if resp.status_code != 200: | ||||
|             logger.warning("SGDB request failed for %s: %s", game_name, resp.status_code) | ||||
|             return "" | ||||
|         text = resp.text.strip() | ||||
|         # Убираем возможные кавычки вокруг строки | ||||
|         if text.startswith('"') and text.endswith('"'): | ||||
|             text = text[1:-1] | ||||
|         if text: | ||||
|             logger.info("Fetched SGDB cover for %s: %s", game_name, text) | ||||
|         return text | ||||
|     except Exception as e: | ||||
|         logger.warning("Failed to fetch SGDB cover for %s: %s", game_name, e) | ||||
|     return "" | ||||
|  | ||||
|  | ||||
| def check_url_exists(url: str) -> bool: | ||||
|     """Check whether a URL returns HTTP 200.""" | ||||
|     try: | ||||
|         r = requests.head(url, timeout=3) | ||||
|         return r.status_code == 200 | ||||
|     except Exception: | ||||
|         return False | ||||
|  | ||||
|  | ||||
| def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]): | ||||
|     """ | ||||
|     Asynchronously fetches detailed app info from Steam API. | ||||
| @@ -629,6 +663,11 @@ def get_full_steam_game_info_async(appid: int, callback: Callable[[dict], None]) | ||||
|         title = decode_text(app_info.get("name", "")) | ||||
|         description = decode_text(app_info.get("short_description", "")) | ||||
|         cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg" | ||||
|         if not check_url_exists(cover): | ||||
|             logger.info("Steam cover not found for %s, trying SGDB", title) | ||||
|             alt_cover = fetch_sgdb_cover(title) | ||||
|             if alt_cover: | ||||
|                 cover = alt_cover | ||||
|  | ||||
|         def on_protondb_tier(tier: str): | ||||
|             def on_anticheat_status(anticheat_status: str): | ||||
| @@ -722,12 +761,15 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla | ||||
|         game_name = desktop_name or exe_name.capitalize() | ||||
|  | ||||
|         if not matching_app: | ||||
|             cover = fetch_sgdb_cover(game_name) or "" | ||||
|             logger.info("Using SGDB cover for non-Steam game '%s': %s", game_name, cover) | ||||
|  | ||||
|             def on_anticheat_status(anticheat_status: str): | ||||
|                 callback({ | ||||
|                     "appid": "", | ||||
|                     "name": decode_text(game_name), | ||||
|                     "description": "", | ||||
|                     "cover": "", | ||||
|                     "cover": cover, | ||||
|                     "controller_support": "", | ||||
|                     "protondb_tier": "", | ||||
|                     "steam_game": "false", | ||||
| @@ -758,6 +800,11 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla | ||||
|             title = decode_text(app_info.get("name", game_name)) | ||||
|             description = decode_text(app_info.get("short_description", "")) | ||||
|             cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg" | ||||
|             if not check_url_exists(cover): | ||||
|                 logger.info("Steam cover not found for %s, trying SGDB", title) | ||||
|                 alt_cover = fetch_sgdb_cover(title) | ||||
|                 if alt_cover: | ||||
|                     cover = alt_cover | ||||
|             controller_support = app_info.get("controller_support", "") | ||||
|  | ||||
|             def on_protondb_tier(tier: str): | ||||
| @@ -957,7 +1004,8 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, | ||||
|         return (False, f"Executable file not found: {exe_path}") | ||||
|  | ||||
|     portproton_dir = get_portproton_location() | ||||
|     if not portproton_dir: | ||||
|     start_sh = get_portproton_start_command() | ||||
|     if not portproton_dir or not start_sh: | ||||
|         logger.error("PortProton directory not found") | ||||
|         return (False, "PortProton directory not found") | ||||
|  | ||||
| @@ -966,17 +1014,12 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, | ||||
|  | ||||
|     safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', game_name.strip()) | ||||
|     script_path = os.path.join(steam_scripts_dir, f"{safe_game_name}.sh") | ||||
|     start_sh_path = os.path.join(portproton_dir, "data", "scripts", "start.sh") | ||||
|  | ||||
|     if not os.path.exists(start_sh_path): | ||||
|         logger.error(f"start.sh not found at {start_sh_path}") | ||||
|         return (False, f"start.sh not found at {start_sh_path}") | ||||
|  | ||||
|     if not os.path.exists(script_path): | ||||
|         script_content = f"""#!/usr/bin/env bash | ||||
| export LD_PRELOAD= | ||||
| export START_FROM_STEAM=1 | ||||
| "{start_sh_path}" "{exe_path}" "$@" | ||||
| "{start_sh}" "{exe_path}" "$@" | ||||
| """ | ||||
|         try: | ||||
|             with open(script_path, "w", encoding="utf-8") as f: | ||||
|   | ||||
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/icons/settings.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8.0005 1c-0.38761 0-0.77522 0.0327-1.1588 0.0979-0.16351 0.0281-0.30273 0.13627-0.37209 0.28935l-0.39088 0.86264c-0.49378 0.16682-0.96454 0.39759-1.4007 0.68616 2.5e-4 0-0.90672-0.2272-0.90672-0.2272-0.161-0.0403-0.33098 3e-3 -0.45442 0.11569-0.57867 0.5285-1.0672 1.1514-1.4451 1.8432-0.0804 0.14721-0.0841 0.32549-0.01 0.47628l0.41938 0.84865c-0.17954 0.49666-0.29567 1.0147-0.346 1.5417l-0.73995 0.57946c-0.13121 0.10289-0.20407 0.26514-0.19431 0.4335 0.0453 0.78981 0.21961 1.5666 0.51558 2.2983 0.0631 0.15587 0.1978 0.27003 0.36005 0.30467l0.91397 0.19559c0.26993 0.45234 0.59572 0.86802 0.96931 1.2363l-0.0161 0.94973c-3e-3 0.16861 0.0766 0.32755 0.21183 0.42484 0.63551 0.45642 1.3414 0.80207 2.0884 1.0229 0.15926 0.0471 0.33077 0.0109 0.45872-0.0963l0.72016-0.60485c0.51582 0.0674 1.0384 0.0674 1.5544 0l0.72016 0.60485c0.12796 0.10722 0.29946 0.14343 0.45872 0.0963 0.74693-0.22083 1.4528-0.56648 2.0883-1.0229 0.13521-0.0973 0.21465-0.25623 0.21189-0.42484l-0.0161-0.94973c0.37359-0.36829 0.69939-0.78372 0.96932-1.2363l0.91396-0.19559c0.16226-0.0347 0.29695-0.1488 0.36005-0.30467 0.29597-0.73174 0.47026-1.5085 0.51558-2.2983 0.01-0.16836-0.0631-0.33061-0.1943-0.4335l-0.73996-0.57946c-0.0501-0.52671-0.16652-1.045-0.34606-1.5417l0.41944-0.84865c0.0746-0.15079 0.0709-0.32907-0.01-0.47628-0.37785-0.69176-0.86638-1.3147-1.445-1.8432-0.12345-0.11258-0.29343-0.15594-0.45443-0.11569l-0.90697 0.2272c-0.43594-0.28857-0.9067-0.51908-1.4005-0.68616l-0.39088-0.86264c-0.0694-0.15308-0.20858-0.26132-0.37209-0.28935-0.38361-0.0653-0.77121-0.0979-1.1588-0.0979zm0 4.1365a2.8152 2.8635 0 0 1 2.8152 2.8636 2.8152 2.8635 0 0 1-2.8152 2.8635 2.8152 2.8635 0 0 1-2.8152-2.8635 2.8152 2.8635 0 0 1 2.8152-2.8636z" fill="#fff" stroke-width=".25254"/></svg> | ||||
| After Width: | Height: | Size: 1.8 KiB | 
| After Width: | Height: | Size: 232 KiB | 
							
								
								
									
										
											BIN
										
									
								
								portprotonqt/themes/standart/images/screenshots/Библиотека.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 225 KiB | 
| Before Width: | Height: | Size: 1.3 MiB | 
							
								
								
									
										
											BIN
										
									
								
								portprotonqt/themes/standart/images/screenshots/Карточка.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 70 KiB | 
| Before Width: | Height: | Size: 364 KiB | 
| Before Width: | Height: | Size: 430 KiB | 
| After Width: | Height: | Size: 238 KiB | 
| Before Width: | Height: | Size: 1.3 MiB | 
| After Width: | Height: | Size: 61 KiB | 
| After Width: | Height: | Size: 38 KiB | 
| Before Width: | Height: | Size: 104 KiB | 
| Before Width: | Height: | Size: 1.0 MiB | 
							
								
								
									
										
											BIN
										
									
								
								portprotonqt/themes/standart/images/screenshots/Темы.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 93 KiB | 
| @@ -1,7 +1,8 @@ | ||||
| from typing import cast | ||||
| from typing import cast, Any | ||||
| from PySide6.QtWidgets import (QFrame, QVBoxLayout, QPushButton, QGridLayout, | ||||
|                                QSizePolicy, QWidget, QLineEdit) | ||||
| from PySide6.QtCore import Qt, Signal, QProcess | ||||
| from PySide6.QtCore import Qt, Signal, QProcess, QSize | ||||
| from PySide6.QtGui import QPixmap, QIcon | ||||
| from portprotonqt.keyboard_layouts import keyboard_layouts | ||||
| from portprotonqt.theme_manager import ThemeManager | ||||
| from portprotonqt.config_utils import read_theme_from_config | ||||
| @@ -43,6 +44,18 @@ class VirtualKeyboard(QFrame): | ||||
|         self.margins = 10 | ||||
|         self.num_cols = 14 | ||||
|  | ||||
|         # Find input_manager and main_window | ||||
|         self.input_manager: Any = None | ||||
|         self.main_window: Any = None | ||||
|         parent_widget: QWidget | None = self._parent | ||||
|         while parent_widget: | ||||
|             if hasattr(parent_widget, 'input_manager'): | ||||
|                 self.input_manager = cast(Any, parent_widget).input_manager | ||||
|                 self.main_window = cast(Any, parent_widget) | ||||
|             parent_widget = cast(QWidget | None, parent_widget.parent()) | ||||
|  | ||||
|  | ||||
|         self.current_theme_name = read_theme_from_config() | ||||
|         self.initUI() | ||||
|         self.hide() | ||||
|  | ||||
| @@ -119,6 +132,34 @@ class VirtualKeyboard(QFrame): | ||||
|         self.buttons: dict[str, QPushButton] = {} | ||||
|         self.update_keyboard() | ||||
|  | ||||
|     def set_gamepad_icon(self, button, icon_type, gtype=''): | ||||
|         """Set gamepad icon on button based on type""" | ||||
|         if icon_type in ['back', 'add_game']: | ||||
|             icon_name = self.main_window.get_button_icon(icon_type, gtype) | ||||
|         else:  # nav left/right | ||||
|             if icon_type in ['left', 'right']: | ||||
|                 direction = icon_type | ||||
|                 icon_name = self.main_window.get_nav_icon(direction, gtype) | ||||
|             else: | ||||
|                 direction = 'left' if icon_type == 'left' else 'right' | ||||
|                 icon_name = self.main_window.get_nav_icon(direction, gtype) | ||||
|  | ||||
|         icon_path = theme_manager.get_theme_image(icon_name, self.current_theme_name) | ||||
|         pixmap = QPixmap() | ||||
|         if icon_path: | ||||
|             pixmap.load(str(icon_path)) | ||||
|         if not pixmap.isNull(): | ||||
|             button.setIcon(QIcon(pixmap)) | ||||
|             button.setIconSize(QSize(20, 20)) | ||||
|             return | ||||
|         else: | ||||
|             # Fallback to placeholder | ||||
|             placeholder = theme_manager.get_theme_image("placeholder", self.current_theme_name) | ||||
|             if placeholder: | ||||
|                 button.setIcon(QIcon(placeholder)) | ||||
|                 button.setIconSize(QSize(20, 20)) | ||||
|                 return | ||||
|  | ||||
|     def update_keyboard(self): | ||||
|         coords = self._save_focused_coords() | ||||
|  | ||||
| @@ -151,6 +192,9 @@ class VirtualKeyboard(QFrame): | ||||
|                     button.setCheckable(True) | ||||
|                     button.setChecked(self.shift_pressed) | ||||
|                     button.clicked.connect(lambda checked: self.on_shift_click(checked)) | ||||
|                     # Add gamepad icon for Shift (RB/R) | ||||
|                     gtype = self.input_manager.gamepad_type | ||||
|                     self.set_gamepad_icon(button, 'right', gtype) | ||||
|                 else: | ||||
|                     button.clicked.connect(lambda checked=False, k=key: self.on_button_click(k)) | ||||
|  | ||||
| @@ -163,6 +207,9 @@ class VirtualKeyboard(QFrame): | ||||
|         shift.setCheckable(True) | ||||
|         shift.setChecked(self.shift_pressed) | ||||
|         shift.clicked.connect(lambda checked: self.on_shift_click(checked)) | ||||
|         # Add gamepad icon for Shift (RB/R) | ||||
|         gtype = self.input_manager.gamepad_type | ||||
|         self.set_gamepad_icon(shift, 'right', gtype) | ||||
|         self.keyboard_layout.addWidget(shift, 3, 11, 1, 3) | ||||
|  | ||||
|         button = QPushButton('CAPS') | ||||
| @@ -179,6 +226,9 @@ class VirtualKeyboard(QFrame): | ||||
|         backspace.setFixedSize(fixed_w, fixed_h) | ||||
|         backspace.pressed.connect(self.on_backspace_pressed) | ||||
|         backspace.released.connect(self.stop_backspace_repeat) | ||||
|         # Add gamepad icon for Backspace (X/Triangle) | ||||
|         gtype = self.input_manager.gamepad_type | ||||
|         self.set_gamepad_icon(backspace, 'add_game', gtype) | ||||
|         self.keyboard_layout.addWidget(backspace, 0, 13, 1, 1) | ||||
|  | ||||
|         enter = QPushButton('Enter') | ||||
| @@ -189,6 +239,9 @@ class VirtualKeyboard(QFrame): | ||||
|         lang = QPushButton('🌐') | ||||
|         lang.setFixedSize(fixed_w, fixed_h) | ||||
|         lang.clicked.connect(self.on_lang_click) | ||||
|         # Add gamepad icon for Lang (LB/L) | ||||
|         gtype = self.input_manager.gamepad_type | ||||
|         self.set_gamepad_icon(lang, 'left', gtype) | ||||
|         self.keyboard_layout.addWidget(lang, 4, 0, 1, 1) | ||||
|  | ||||
|         clear = QPushButton('Clear') | ||||
| @@ -219,6 +272,9 @@ class VirtualKeyboard(QFrame): | ||||
|         hide_button = QPushButton('Hide') | ||||
|         hide_button.setFixedSize(fixed_w * 2 + self.spacing, fixed_h) | ||||
|         hide_button.clicked.connect(self.hide) | ||||
|         # Add gamepad icon for Hide (B/Circle) | ||||
|         gtype = self.input_manager.gamepad_type | ||||
|         self.set_gamepad_icon(hide_button, 'back', gtype) | ||||
|         self.keyboard_layout.addWidget(hide_button, 4, 12, 1, 2) | ||||
|  | ||||
|         if coords: | ||||
|   | ||||
| @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" | ||||
|  | ||||
| [project] | ||||
| name = "portprotonqt" | ||||
| version = "0.1.7" | ||||
| version = "0.1.8" | ||||
| description = "A project to rewrite PortProton (PortWINE) using PySide" | ||||
| readme = "README.md" | ||||
| license = { text = "GPL-3.0" } | ||||
|   | ||||