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
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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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')

View File

@@ -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

View File

@@ -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:

View File

@@ -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)

View File

@@ -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()

View File

@@ -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:
"""
Находит первый доступный геймпад.

View File

@@ -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)

View File

@@ -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."""

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]
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" }

2
uv.lock generated
View File

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