Compare commits
10 Commits
7df6ad3b80
...
renovate/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92572bf5a1 | ||
|
438e9737ea
|
|||
|
2d39a4c740
|
|||
|
567203b0b0
|
|||
|
502cbc5030
|
|||
|
9b61215152
|
|||
|
10d3fe8ab4
|
|||
|
a568ad9ef8
|
|||
|
f074843fc8
|
|||
|
4ab078b93e
|
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
10
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -1456,52 +1456,69 @@ 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
|
||||||
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 # просто ждём, не блокируем
|
||||||
|
|
||||||
if device is not None:
|
try:
|
||||||
action = device.action
|
device = monitor.poll(timeout=0)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Monitor poll failed: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
# Фильтруем только джойстики на уровне callback
|
if not device:
|
||||||
# Это предотвращает обработку мышей/клавиатур/и т.д.
|
continue
|
||||||
if action and self._is_joystick_device(device):
|
|
||||||
logger.info(f"Joystick hotplug event: {action} for {device.sys_name}")
|
action = device.action
|
||||||
self.handle_udev_event(action, device)
|
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")
|
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:
|
||||||
"""
|
"""
|
||||||
Быстрая проверка: является ли устройство джойстиком.
|
Быстрая проверка: является ли устройство джойстиком.
|
||||||
|
|||||||
@@ -3056,56 +3056,73 @@ 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:
|
|
||||||
# Принудительное закрытие: завершаем процессы и приложение
|
|
||||||
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}")
|
|
||||||
|
|
||||||
self.game_processes = [] # Очищаем список процессов
|
if minimize_to_tray:
|
||||||
|
# Просто сворачиваем в трей
|
||||||
# Очищаем таймеры
|
|
||||||
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()
|
|
||||||
event.ignore()
|
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)
|
||||||
|
|||||||
|
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]
|
[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" }
|
||||||
|
|||||||