Compare commits
	
		
			13 Commits
		
	
	
		
			7df6ad3b80
			...
			v0.1.8
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | b59ee5ae8e | ||
| 33176590fd | |||
| 8046065929 | |||
|  | fbad5add6c | ||
| 438e9737ea | |||
| 2d39a4c740 | |||
| 567203b0b0 | |||
| 502cbc5030 | |||
| 9b61215152 | |||
| 10d3fe8ab4 | |||
| a568ad9ef8 | |||
| f074843fc8 | |||
| 4ab078b93e | 
| @@ -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 | ||||
|   | ||||
| @@ -16,7 +16,7 @@ repos: | ||||
|       - id: uv-lock | ||||
|  | ||||
|   - repo: https://github.com/astral-sh/ruff-pre-commit | ||||
|     rev: v0.14.0 | ||||
|     rev: v0.14.1 | ||||
|     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 | ||||
|  | ||||
|   | ||||
| @@ -11,7 +11,7 @@ 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: | ||||
|   | ||||
| @@ -1034,8 +1034,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) | ||||
|  | ||||
|   | ||||
| @@ -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() | ||||
|   | ||||
| @@ -1440,6 +1440,7 @@ class InputManager(QObject): | ||||
|         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) | ||||
| @@ -1456,52 +1457,70 @@ class InputManager(QObject): | ||||
|         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 событий без MonitorObserver. | ||||
|         Использует monitor.poll() с таймаутом для корректного завершения. | ||||
|         Безопасный неблокирующий udev monitor для геймпадов. | ||||
|         Использует select.poll() вместо блокирующего monitor.poll(). | ||||
|         """ | ||||
|         try: | ||||
|             logger.info("Starting udev monitor...") | ||||
|             monitor = Monitor.from_netlink(self.udev_context) | ||||
|             monitor.filter_by(subsystem='input') | ||||
|             monitor.start() | ||||
|             logger.info("Monitor started, draining initial events...") | ||||
|  | ||||
|             # КРИТИЧНО: При старте udev отправляет события о ВСЕХ существующих устройствах | ||||
|             # Это может быть 10-50+ событий, которые блокируют инициализацию | ||||
|             # Решение: дренируем (игнорируем) все события за первые 500ms | ||||
|             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: | ||||
|                 device = monitor.poll(timeout=0.1) | ||||
|                 if device is not None: | ||||
|                 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...") | ||||
|  | ||||
|             # Основной цикл опроса с таймаутом 1 секунда | ||||
|             # Основной цикл | ||||
|             while self.running: | ||||
|                 # poll() возвращает None при таймауте - не блокирует навсегда | ||||
|                 device = monitor.poll(timeout=1.0) | ||||
|                 events = poller.poll(1000)  # 1 сек таймаут | ||||
|                 if not events: | ||||
|                     continue  # просто ждём, не блокируем | ||||
|  | ||||
|                 if device is not None: | ||||
|                     action = device.action | ||||
|                 try: | ||||
|                     device = monitor.poll(timeout=0) | ||||
|                 except Exception as e: | ||||
|                     logger.debug(f"Monitor poll failed: {e}") | ||||
|                     continue | ||||
|  | ||||
|                     # Фильтруем только джойстики на уровне callback | ||||
|                     # Это предотвращает обработку мышей/клавиатур/и т.д. | ||||
|                     if action and self._is_joystick_device(device): | ||||
|                         logger.info(f"Joystick hotplug event: {action} for {device.sys_name}") | ||||
|                         self.handle_udev_event(action, device) | ||||
|                 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: | ||||
|         """ | ||||
|         Быстрая проверка: является ли устройство джойстиком. | ||||
| @@ -1575,7 +1594,6 @@ class InputManager(QObject): | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error in hotplug handler: {e}", exc_info=True) | ||||
|  | ||||
|  | ||||
|     def check_gamepad(self) -> None: | ||||
|         """ | ||||
|         Проверка и подключение геймпада. | ||||
| @@ -1584,18 +1602,23 @@ class InputManager(QObject): | ||||
|         try: | ||||
|             new_gamepad = self.find_gamepad() | ||||
|  | ||||
|             # Проверяем, действительно ли это новый геймпад | ||||
|             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: | ||||
|                     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=self.monitor_gamepad, | ||||
|                         target=start_monitoring, | ||||
|                         daemon=True | ||||
|                     ) | ||||
|                     self.gamepad_thread.start() | ||||
| @@ -1605,12 +1628,11 @@ class InputManager(QObject): | ||||
|                         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 = None | ||||
|  | ||||
|                 if self.gamepad_thread: | ||||
|                 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(): | ||||
| @@ -1619,7 +1641,6 @@ class InputManager(QObject): | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error checking gamepad: {e}", exc_info=True) | ||||
|  | ||||
|  | ||||
|     def find_gamepad(self) -> InputDevice | None: | ||||
|         """ | ||||
|         Находит первый доступный геймпад. | ||||
|   | ||||
| @@ -1253,7 +1253,15 @@ 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) | ||||
|  | ||||
| @@ -3056,56 +3064,73 @@ class MainWindow(QMainWindow): | ||||
|         """Обработчик закрытия окна: проверяет настройку minimize_to_tray. | ||||
|         Если True — сворачиваем в трей (по умолчанию). Иначе — полностью закрываем. | ||||
|         """ | ||||
|         minimize_to_tray = read_minimize_to_tray()  # Импорт read_minimize_to_tray из config_utils | ||||
|         if hasattr(self, 'is_exiting') and self.is_exiting or not minimize_to_tray: | ||||
|             # Принудительное закрытие: завершаем процессы и приложение | ||||
|             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 = 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) | ||||
|             save_auto_card_size(self.auto_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.""" | ||||
|   | ||||
| 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 | 
| @@ -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" } | ||||
|   | ||||