13 Commits

Author SHA1 Message Date
Renovate Bot
b59ee5ae8e chore(deps): update archlinux:base-devel docker digest to 87a967f 2025-10-19 12:07:20 +00:00
33176590fd feat: Make autoinstall games loading asynchronous with caching
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-19 17:03:26 +05:00
8046065929 refactor(gamepad): replace busy-wait with threading.Event for monitor readiness
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-19 11:00:22 +05:00
Renovate Bot
fbad5add6c chore(deps): update pre-commit hook astral-sh/ruff-pre-commit to v0.14.1 2025-10-19 00:01:23 +00:00
438e9737ea chore(release): drop sha256 sums
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 21:07:37 +05:00
2d39a4c740 fix: fix CloseEvent on native package
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 21:06:23 +05:00
567203b0b0 chore: bump to 0.1.8
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 18:22:32 +05:00
502cbc5030 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 18:20:50 +05:00
9b61215152 chore(theme): update screenshots
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 18:17:47 +05:00
10d3fe8ab4 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 13:40:56 +05:00
a568ad9ef8 fix(add_game_dialog): prevent overwriting manually entered game name
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 13:09:58 +05:00
f074843fc8 fix: prevent udev monitor hang by using non-blocking poll with timeout
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 12:53:47 +05:00
4ab078b93e fix: sync card_width between GameLibraryManager and MainWindow to prevent config overwrite
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 12:17:17 +05:00
29 changed files with 279 additions and 146 deletions

View File

@@ -94,7 +94,7 @@ jobs:
name: Build Arch Package name: Build Arch Package
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
container: container:
image: archlinux:base-devel@sha256:06ab929f935145dd65994a89dd06651669ea28d43c812f3e24de990978511821 image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
volumes: volumes:
- /usr:/usr-host - /usr:/usr-host
- /opt:/opt-host - /opt:/opt-host

View File

@@ -8,7 +8,7 @@ on:
env: env:
# Common version, will be used for tagging the release # Common version, will be used for tagging the release
VERSION: 0.1.7 VERSION: 0.1.8
PKGDEST: "/tmp/portprotonqt" PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt" PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@@ -188,4 +188,4 @@ jobs:
tag_name: v${{ env.VERSION }} tag_name: v${{ env.VERSION }}
prerelease: true prerelease: true
files: release/**/* files: release/**/*
sha256sum: true sha256sum: false

View File

@@ -138,7 +138,7 @@ jobs:
needs: changes needs: changes
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch' if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
container: container:
image: archlinux:base-devel@sha256:06ab929f935145dd65994a89dd06651669ea28d43c812f3e24de990978511821 image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
volumes: volumes:
- /usr:/usr-host - /usr:/usr-host
- /opt:/opt-host - /opt:/opt-host

View File

@@ -16,7 +16,7 @@ repos:
- id: uv-lock - id: uv-lock
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.0 rev: v0.14.1
hooks: hooks:
- id: ruff-check - id: ruff-check

View File

@@ -3,16 +3,24 @@
Все заметные изменения в этом проекте фиксируются в этом файле. Все заметные изменения в этом проекте фиксируются в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/). Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [0.1.8] - 2025-10-18
### Added ### Added
- В настройки добавлен пункт для выбора типа геймпада для подсказок по управлению - В настройки добавлен пункт для выбора типа геймпада для подсказок по управлению
- В настройки добавлен пункт для выбора сворачивать ли приложение в трей или нет
- К диалогу добавления игры, Winetricks, диалогу выбора файлов и виртуальной клавиатуре добавлены подсказки по управлению с геймпада
- Во вкладку автоустановок добавлен слайдер изменения размера карточек (они со слайдером в библиотеке независимы)
### Changed ### Changed
- При завершении автоустановки приложение больше не перезапускается - При завершении автоустановки приложение больше не перезапускается
- Выбор exe в диалоге добавления игры больше не перезаписывает введенное в поле название
- Обновлены и дополнены скриншоты темы
### Fixed ### Fixed
- Исправлено наложение карточек при смене фильтра игр - Исправлено наложение карточек при смене фильтра игр
- Исправлена невозможность запуска приложения без подключёного геймпада
- Исправлена невозможность установки компонентов Winetricks через геймпад
- Ресиверы и виртуальные устройства больше не считаются за геймпад
### Contributors ### Contributors

View File

@@ -36,7 +36,7 @@ AppDir:
id: ru.linux_gaming.PortProtonQt id: ru.linux_gaming.PortProtonQt
name: PortProtonQt name: PortProtonQt
icon: ru.linux_gaming.PortProtonQt icon: ru.linux_gaming.PortProtonQt
version: 0.1.7 version: 0.1.8
exec: usr/bin/python3 exec: usr/bin/python3
exec_args: "-m portprotonqt.app $@" exec_args: "-m portprotonqt.app $@"
apt: apt:

View File

@@ -1,5 +1,5 @@
pkgname=portprotonqt pkgname=portprotonqt
pkgver=0.1.7 pkgver=0.1.8
pkgrel=1 pkgrel=1
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store" pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
arch=('any') arch=('any')

View File

@@ -1,5 +1,5 @@
%global pypi_name portprotonqt %global pypi_name portprotonqt
%global pypi_version 0.1.7 %global pypi_version 0.1.8
%global oname PortProtonQt %global oname PortProtonQt
%global _python_no_extras_requires 1 %global _python_no_extras_requires 1

View File

@@ -11,7 +11,7 @@ from portprotonqt.cli import parse_args
__app_id__ = "ru.linux_gaming.PortProtonQt" __app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt" __app_name__ = "PortProtonQt"
__app_version__ = "0.1.7" __app_version__ = "0.1.8"
def get_version(): def get_version():
try: try:

View File

@@ -1034,8 +1034,8 @@ class AddGameDialog(QDialog):
"""Обработчик выбора файла в FileExplorer""" """Обработчик выбора файла в FileExplorer"""
self.exeEdit.setText(file_path) self.exeEdit.setText(file_path)
self.last_exe_path = file_path # Update last selected exe 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] game_name = os.path.splitext(os.path.basename(file_path))[0]
self.nameEdit.setText(game_name) self.nameEdit.setText(game_name)

View File

@@ -33,6 +33,7 @@ class MainWindowProtocol(Protocol):
# Required attributes # Required attributes
searchEdit: CustomLineEdit searchEdit: CustomLineEdit
_last_card_width: int _last_card_width: int
card_width: int
current_hovered_card: GameCard | None current_hovered_card: GameCard | None
current_focused_card: GameCard | None current_focused_card: GameCard | None
gamesListWidget: QWidget | None gamesListWidget: QWidget | None
@@ -128,6 +129,8 @@ class GameLibraryManager:
self.card_width = self.sizeSlider.value() self.card_width = self.sizeSlider.value()
self.sizeSlider.setToolTip(f"{self.card_width} px") self.sizeSlider.setToolTip(f"{self.card_width} px")
save_card_size(self.card_width) 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(): for card in self.game_card_cache.values():
card.update_card_size(self.card_width) card.update_card_size(self.card_width)
self.update_game_grid() self.update_game_grid()

View File

@@ -1440,6 +1440,7 @@ class InputManager(QObject):
self.udev_context = Context() self.udev_context = Context()
self.Devices = Devices self.Devices = Devices
self.monitor_ready = False self.monitor_ready = False
self.monitor_event = threading.Event()
# Подключаем сигнал hotplug к обработчику в главном потоке # Подключаем сигнал hotplug к обработчику в главном потоке
self.gamepad_hotplug.connect(self._on_gamepad_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() threading.Thread(target=self.run_udev_monitor, daemon=True).start()
logger.info("Gamepad support initialized with hotplug (evdev + pyudev)") logger.info("Gamepad support initialized with hotplug (evdev + pyudev)")
def run_udev_monitor(self) -> None: def run_udev_monitor(self) -> None:
""" """
Неблокирующий опрос udev событий без MonitorObserver. Безопасный неблокирующий udev monitor для геймпадов.
Использует monitor.poll() с таймаутом для корректного завершения. Использует select.poll() вместо блокирующего monitor.poll().
""" """
try: try:
logger.info("Starting udev monitor...") logger.info("Starting udev monitor...")
monitor = Monitor.from_netlink(self.udev_context) monitor = Monitor.from_netlink(self.udev_context)
monitor.filter_by(subsystem='input') monitor.filter_by(subsystem='input')
monitor.start()
logger.info("Monitor started, draining initial events...")
# КРИТИЧНО: При старте udev отправляет события о ВСЕХ существующих устройствах try:
# Это может быть 10-50+ событий, которые блокируют инициализацию monitor.start()
# Решение: дренируем (игнорируем) все события за первые 500ms 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() drain_start = time.time()
drained_count = 0 drained_count = 0
while time.time() - drain_start < 0.5: while time.time() - drain_start < 0.5:
device = monitor.poll(timeout=0.1) events = poller.poll(100)
if device is not None: if not events:
continue
try:
_ = monitor.poll(timeout=0) # просто читаем, не обрабатываем
drained_count += 1 drained_count += 1
except Exception:
break
self.monitor_ready = True self.monitor_ready = True
self.monitor_event.set()
logger.info(f"Drained {drained_count} initial events, now monitoring hotplug...") logger.info(f"Drained {drained_count} initial events, now monitoring hotplug...")
# Основной цикл опроса с таймаутом 1 секунда # Основной цикл
while self.running: while self.running:
# poll() возвращает None при таймауте - не блокирует навсегда events = poller.poll(1000) # 1 сек таймаут
device = monitor.poll(timeout=1.0) 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
if device is not None:
action = device.action action = device.action
# Фильтруем только джойстики на уровне callback
# Это предотвращает обработку мышей/клавиатур/и т.д.
if action and self._is_joystick_device(device): if action and self._is_joystick_device(device):
logger.info(f"Joystick hotplug event: {action} for {device.sys_name}") logger.info(f"Joystick hotplug event: {action} for {device.sys_name}")
# отправляем сигнал в Qt-поток
self.handle_udev_event(action, device) self.handle_udev_event(action, device)
logger.info("udev monitor stopped gracefully") logger.info("udev monitor stopped gracefully")
except Exception as e: except Exception as e:
logger.error(f"Error in udev monitor: {e}", exc_info=True) logger.error(f"Error in udev monitor: {e}", exc_info=True)
def _is_joystick_device(self, device: Device) -> bool: def _is_joystick_device(self, device: Device) -> bool:
""" """
Быстрая проверка: является ли устройство джойстиком. Быстрая проверка: является ли устройство джойстиком.
@@ -1575,7 +1594,6 @@ class InputManager(QObject):
except Exception as e: except Exception as e:
logger.error(f"Error in hotplug handler: {e}", exc_info=True) logger.error(f"Error in hotplug handler: {e}", exc_info=True)
def check_gamepad(self) -> None: def check_gamepad(self) -> None:
""" """
Проверка и подключение геймпада. Проверка и подключение геймпада.
@@ -1584,18 +1602,23 @@ class InputManager(QObject):
try: try:
new_gamepad = self.find_gamepad() new_gamepad = self.find_gamepad()
# Проверяем, действительно ли это новый геймпад
if new_gamepad: if new_gamepad:
if not self.gamepad or new_gamepad.path != self.gamepad.path: if not self.gamepad or new_gamepad.path != self.gamepad.path:
logger.info(f"Gamepad connected: {new_gamepad.name} at {new_gamepad.path}") logger.info(f"Gamepad connected: {new_gamepad.name} at {new_gamepad.path}")
self.stop_rumble() self.stop_rumble()
self.gamepad = new_gamepad 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) 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( self.gamepad_thread = threading.Thread(
target=self.monitor_gamepad, target=start_monitoring,
daemon=True daemon=True
) )
self.gamepad_thread.start() self.gamepad_thread.start()
@@ -1605,12 +1628,11 @@ class InputManager(QObject):
self.toggle_fullscreen.emit(True) self.toggle_fullscreen.emit(True)
elif self.gamepad and not any(self.gamepad.path == path for path in list_devices()): elif self.gamepad and not any(self.gamepad.path == path for path in list_devices()):
# Геймпад был подключён, но теперь его нет в системе
logger.info("Gamepad no longer detected") logger.info("Gamepad no longer detected")
self.stop_rumble() self.stop_rumble()
self.gamepad = None self.gamepad = None
if self.gamepad_thread: if self.gamepad_thread and self.gamepad_thread.is_alive():
self.gamepad_thread.join(timeout=2.0) self.gamepad_thread.join(timeout=2.0)
if read_auto_fullscreen_gamepad() and not read_fullscreen_config(): if read_auto_fullscreen_gamepad() and not read_fullscreen_config():
@@ -1619,7 +1641,6 @@ class InputManager(QObject):
except Exception as e: except Exception as e:
logger.error(f"Error checking gamepad: {e}", exc_info=True) logger.error(f"Error checking gamepad: {e}", exc_info=True)
def find_gamepad(self) -> InputDevice | None: def find_gamepad(self) -> InputDevice | None:
""" """
Находит первый доступный геймпад. Находит первый доступный геймпад.

View File

@@ -1253,7 +1253,15 @@ class MainWindow(QMainWindow):
# Показываем прогресс # Показываем прогресс
self.autoInstallProgress.setVisible(True) self.autoInstallProgress.setVisible(True)
self.autoInstallProgress.setRange(0, 0) 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) self.stackedWidget.addWidget(autoInstallPage)
@@ -3056,10 +3064,34 @@ class MainWindow(QMainWindow):
"""Обработчик закрытия окна: проверяет настройку minimize_to_tray. """Обработчик закрытия окна: проверяет настройку minimize_to_tray.
Если True — сворачиваем в трей (по умолчанию). Иначе — полностью закрываем. Если True — сворачиваем в трей (по умолчанию). Иначе — полностью закрываем.
""" """
minimize_to_tray = read_minimize_to_tray() # Импорт read_minimize_to_tray из config_utils minimize_to_tray = read_minimize_to_tray()
if hasattr(self, 'is_exiting') and self.is_exiting or not minimize_to_tray:
# Принудительное закрытие: завершаем процессы и приложение if minimize_to_tray:
for proc in self.game_processes: # Просто сворачиваем в трей
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: try:
parent = psutil.Process(proc.pid) parent = psutil.Process(proc.pid)
children = parent.children(recursive=True) children = parent.children(recursive=True)
@@ -3069,43 +3101,36 @@ class MainWindow(QMainWindow):
child.terminate() child.terminate()
except psutil.NoSuchProcess: except psutil.NoSuchProcess:
logger.debug(f"Child process {child.pid} already terminated") logger.debug(f"Child process {child.pid} already terminated")
psutil.wait_procs(children, timeout=5) psutil.wait_procs(children, timeout=5)
for child in children: for child in children:
if child.is_running(): if child.is_running():
logger.debug(f"Killing child process {child.pid}") logger.debug(f"Killing child process {child.pid}")
child.kill() child.kill()
logger.debug(f"Terminating process group {proc.pid}") logger.debug(f"Terminating process group {proc.pid}")
os.killpg(os.getpgid(proc.pid), signal.SIGTERM) os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (psutil.NoSuchProcess, ProcessLookupError) as e: except (psutil.NoSuchProcess, ProcessLookupError) as e:
logger.debug(f"Process {proc.pid} already terminated: {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 = [] # Очищаем список процессов self.game_processes = []
# Очищаем таймеры # Универсальная остановка и удаление таймеров
if hasattr(self, 'games_load_timer') and self.games_load_timer is not None and self.games_load_timer.isActive(): timers = [
self.games_load_timer.stop() "games_load_timer",
if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer is not None and self.settingsDebounceTimer.isActive(): "settingsDebounceTimer",
self.settingsDebounceTimer.stop() "searchDebounceTimer",
if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer is not None and self.searchDebounceTimer.isActive(): "checkProcessTimer",
self.searchDebounceTimer.stop() "wine_monitor_timer",
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
# Сохраняем настройки окна for tname in timers:
if not read_fullscreen_config(): timer = getattr(self, tname, None)
logger.debug(f"Saving window geometry: {self.width()}x{self.height()}") if timer and timer.isActive():
save_window_geometry(self.width(), self.height()) timer.stop()
save_card_size(self.card_width) if timer:
save_auto_card_size(self.auto_card_width) timer.deleteLater()
setattr(self, tname, None)
event.accept()
else:
# Сворачиваем в трей вместо закрытия
self.hide()
event.ignore()

View File

@@ -6,13 +6,16 @@ import urllib.parse
import time import time
import glob import glob
import re import re
import hashlib
from collections.abc import Callable from collections.abc import Callable
from PySide6.QtCore import QThread, Signal
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.config_utils import get_portproton_location from portprotonqt.config_utils import get_portproton_location
logger = get_logger(__name__) logger = get_logger(__name__)
CACHE_DURATION = 30 * 24 * 60 * 60 # 30 days in seconds CACHE_DURATION = 30 * 24 * 60 * 60 # 30 days in seconds
AUTOINSTALL_CACHE_DURATION = 3600 # 1 hour for autoinstall cache
def normalize_name(s): def normalize_name(s):
""" """
@@ -59,6 +62,7 @@ class PortProtonAPI:
self.repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 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.builtin_custom_folder = os.path.join(self.repo_root, "custom_data")
self._topics_data = None self._topics_data = None
self._autoinstall_cache = None # New: In-memory cache
def _get_game_dir(self, exe_name: str) -> str: def _get_game_dir(self, exe_name: str) -> str:
game_dir = os.path.join(self.custom_data_dir, exe_name) game_dir = os.path.join(self.custom_data_dir, exe_name)
@@ -231,38 +235,113 @@ class PortProtonAPI:
logger.error(f"Failed to parse {file_path}: {e}") logger.error(f"Failed to parse {file_path}: {e}")
return None, None return None, None
def get_autoinstall_games_async(self, callback: Callable[[list[tuple]], None]) -> None: def _compute_scripts_signature(self, auto_dir: str) -> str:
"""Load auto-install games with user/builtin covers (no async download here).""" """Compute a hash-based signature of the autoinstall scripts to detect changes."""
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): if not os.path.exists(auto_dir):
callback(games) return ""
scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
# 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()
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
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}")
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
# No cache: Start background thread
class AutoinstallWorker(QThread):
finished = Signal(list)
api: "PortProtonAPI"
portproton_location: str | None
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 return
scripts = sorted(glob.glob(os.path.join(auto_dir, "*"))) scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
if not scripts: if not scripts:
callback(games) self.finished.emit(games)
return return
xdg_data_home = os.getenv("XDG_DATA_HOME", xdg_data_home = os.getenv(
os.path.join(os.path.expanduser("~"), ".local", "share")) "XDG_DATA_HOME",
base_autoinstall_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall") 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) os.makedirs(base_autoinstall_dir, exist_ok=True)
for script_path in scripts: for script_path in scripts:
display_name, exe_name = self.parse_autoinstall_script(script_path) display_name, exe_name = self.api.parse_autoinstall_script(script_path)
script_name = os.path.splitext(os.path.basename(script_path))[0] script_name = os.path.splitext(os.path.basename(script_path))[0]
if not (display_name and exe_name): if not (display_name and exe_name):
continue continue
exe_name = os.path.splitext(exe_name)[0] # Без .exe exe_name = os.path.splitext(exe_name)[0]
user_game_folder = os.path.join(base_autoinstall_dir, exe_name) user_game_folder = os.path.join(base_autoinstall_dir, exe_name)
os.makedirs(user_game_folder, exist_ok=True) os.makedirs(user_game_folder, exist_ok=True)
# Поиск обложки # Find cover
cover_path = "" cover_path = ""
user_files = set(os.listdir(user_game_folder)) if os.path.exists(user_game_folder) else set() user_files = (
set(os.listdir(user_game_folder))
if os.path.exists(user_game_folder)
else set()
)
for ext in [".jpg", ".png", ".jpeg", ".bmp"]: for ext in [".jpg", ".png", ".jpeg", ".bmp"]:
candidate = f"cover{ext}" candidate = f"cover{ext}"
if candidate in user_files: if candidate in user_files:
@@ -272,26 +351,23 @@ class PortProtonAPI:
if not cover_path: if not cover_path:
logger.debug(f"No local cover found for autoinstall {exe_name}") logger.debug(f"No local cover found for autoinstall {exe_name}")
# Формируем кортеж игры (добавлен exe_name в конец)
game_tuple = ( game_tuple = (
display_name, # name display_name, "", cover_path, "", f"autoinstall:{script_name}",
"", # description "", "Never", "0h 0m", "", "", 0, 0, "autoinstall", exe_name
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) games.append(game_tuple)
callback(games) 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): def _load_topics_data(self):
"""Load and cache linux_gaming_topics_min.json from the archive.""" """Load and cache linux_gaming_topics_min.json from the archive."""

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "portprotonqt" name = "portprotonqt"
version = "0.1.7" version = "0.1.8"
description = "A project to rewrite PortProton (PortWINE) using PySide" description = "A project to rewrite PortProton (PortWINE) using PySide"
readme = "README.md" readme = "README.md"
license = { text = "GPL-3.0" } license = { text = "GPL-3.0" }

2
uv.lock generated
View File

@@ -527,7 +527,7 @@ wheels = [
[[package]] [[package]]
name = "portprotonqt" name = "portprotonqt"
version = "0.1.7" version = "0.1.8"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "babel" }, { name = "babel" },