Compare commits
	
		
			72 Commits
		
	
	
		
			9452bfda2e
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						e07f3f06bc
	
				 | 
					
					
						|||
| 
						
						
							
						
						16a3f4e09a
	
				 | 
					
					
						|||
| 
						
						
							
						
						a448ba29b0
	
				 | 
					
					
						|||
| 
						
						
							
						
						06e55db54d
	
				 | 
					
					
						|||
| 
						
						
							
						
						5fce23f261
	
				 | 
					
					
						|||
| 
						 | 
					96ad40d625 | ||
| 
						 | 
					a30f6f2e74 | ||
| 
						
						
							
						
						0231073b19
	
				 | 
					
					
						|||
| 
						
						
							
						
						dec24429f5
	
				 | 
					
					
						|||
| 
						
						
							
						
						4a758f3b3c
	
				 | 
					
					
						|||
| 
						
						
							
						
						0853dd1579
	
				 | 
					
					
						|||
| 
						
						
							
						
						bbb87c0455
	
				 | 
					
					
						|||
| 
						
						
							
						
						b32a71a125
	
				 | 
					
					
						|||
| 
						 | 
					bddf9f850a | ||
| 
						 | 
					a9c3cfa167 | ||
| 
						
						
							
						
						7675bc4cdc
	
				 | 
					
					
						|||
| 
						
						
							
						
						ffa203f019
	
				 | 
					
					
						|||
| 
						
						
							
						
						3eed25ecee
	
				 | 
					
					
						|||
| 
						
						
							
						
						3736bb279e
	
				 | 
					
					
						|||
| 
						 | 
					b59ee5ae8e | ||
| 
						
						
							
						
						33176590fd
	
				 | 
					
					
						|||
| 
						
						
							
						
						8046065929
	
				 | 
					
					
						|||
| 
						 | 
					fbad5add6c | ||
| 
						
						
							
						
						438e9737ea
	
				 | 
					
					
						|||
| 
						
						
							
						
						2d39a4c740
	
				 | 
					
					
						|||
| 
						
						
							
						
						567203b0b0
	
				 | 
					
					
						|||
| 
						
						
							
						
						502cbc5030
	
				 | 
					
					
						|||
| 
						
						
							
						
						9b61215152
	
				 | 
					
					
						|||
| 
						
						
							
						
						10d3fe8ab4
	
				 | 
					
					
						|||
| 
						
						
							
						
						a568ad9ef8
	
				 | 
					
					
						|||
| 
						
						
							
						
						f074843fc8
	
				 | 
					
					
						|||
| 
						
						
							
						
						4ab078b93e
	
				 | 
					
					
						|||
| 
						
						
							
						
						7df6ad3b80
	
				 | 
					
					
						|||
| 
						
						
							
						
						464ad0fe9c
	
				 | 
					
					
						|||
| 
						
						
							
						
						cde92885d4
	
				 | 
					
					
						|||
| 
						
						
							
						
						120c7b319c
	
				 | 
					
					
						|||
| 
						
						
							
						
						596aed0077
	
				 | 
					
					
						|||
| 
						
						
							
						
						6fc6cb1e02
	
				 | 
					
					
						|||
| 
						
						
							
						
						186e28a19b
	
				 | 
					
					
						|||
| 
						
						
							
						
						28e4d1e77c
	
				 | 
					
					
						|||
| 
						
						
							
						
						fff1f888c4
	
				 | 
					
					
						|||
| 
						
						
							
						
						fdd5a0a3d5
	
				 | 
					
					
						|||
| 
						
						
							
						
						792e52d981
	
				 | 
					
					
						|||
| 
						
						
							
						
						84d5e46a74
	
				 | 
					
					
						|||
| 
						
						
							
						
						4bc764d568
	
				 | 
					
					
						|||
| 
						
						
							
						
						9a18aa037e
	
				 | 
					
					
						|||
| 
						
						
							
						
						ed62d2d1c4
	
				 | 
					
					
						|||
| 
						
						
							
						
						accc9b18b6
	
				 | 
					
					
						|||
| 
						
						
							
						
						82249d7eab
	
				 | 
					
					
						|||
| 
						
						
							
						
						476c896940
	
				 | 
					
					
						|||
| 
						
						
							
						
						b1047ba18e
	
				 | 
					
					
						|||
| 
						
						
							
						
						987199d8e6
	
				 | 
					
					
						|||
| 
						 | 
					ef1acd4581 | ||
| 
						
						
							
						
						96f884904c
	
				 | 
					
					
						|||
| 
						
						
							
						
						b856a2afae
	
				 | 
					
					
						|||
| 
						
						
							
						
						55ef0030e6
	
				 | 
					
					
						|||
| 
						
						
							
						
						8aaeaa4824
	
				 | 
					
					
						|||
| 
						
						
							
						
						f55372b480
	
				 | 
					
					
						|||
| 
						
						
							
						
						4d6f32f053
	
				 | 
					
					
						|||
| 
						
						
							
						
						a2f5141b20
	
				 | 
					
					
						|||
| 
						
						
							
						
						e3cb2857e7
	
				 | 
					
					
						|||
| 
						
						
							
						
						efe8a35832
	
				 | 
					
					
						|||
| 
						
						
							
						
						61fae97dad
	
				 | 
					
					
						|||
| 
						
						
							
						
						5442100f64
	
				 | 
					
					
						|||
| 
						
						
							
						
						2d6ef84798
	
				 | 
					
					
						|||
| 
						 | 
					f4aee15b5d | ||
| 
						
						
							
						
						87a65108a5
	
				 | 
					
					
						|||
| 
						
						
							
						
						bb617708ac
	
				 | 
					
					
						|||
| 
						
						
							
						
						1cf332cd87
	
				 | 
					
					
						|||
| 
						
						
							
						
						577ad4d3a3
	
				 | 
					
					
						|||
| 
						
						
							
						
						ef3f2d6e96
	
				 | 
					
					
						|||
| 
						
						
							
						
						657d7728a6
	
				 | 
					
					
						
@@ -62,7 +62,7 @@ jobs:
 | 
			
		||||
      - name: Install build dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
          dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \
 | 
			
		||||
                         python3-build pyproject-rpm-macros python3-setuptools \
 | 
			
		||||
                         python3-build pyproject-rpm-macros systemd-rpm-macros python3-setuptools \
 | 
			
		||||
                         redhat-rpm-config nodejs npm
 | 
			
		||||
 | 
			
		||||
      - name: Setup rpmbuild environment
 | 
			
		||||
@@ -94,7 +94,7 @@ jobs:
 | 
			
		||||
    name: Build Arch Package
 | 
			
		||||
    runs-on: ubuntu-22.04
 | 
			
		||||
    container:
 | 
			
		||||
      image: archlinux:base-devel@sha256:b3809917ab5a7840d42237f5f92d92660cd036bd75ae343e7825e6a24401f166
 | 
			
		||||
      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.6
 | 
			
		||||
  VERSION: 0.1.8
 | 
			
		||||
  PKGDEST: "/tmp/portprotonqt"
 | 
			
		||||
  PACKAGE: "portprotonqt"
 | 
			
		||||
  GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
 | 
			
		||||
@@ -119,7 +119,7 @@ jobs:
 | 
			
		||||
      - name: Install build dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
          dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \
 | 
			
		||||
                         python3-build pyproject-rpm-macros python3-setuptools \
 | 
			
		||||
                         python3-build pyproject-rpm-macros systemd-rpm-macros python3-setuptools \
 | 
			
		||||
                         redhat-rpm-config nodejs npm
 | 
			
		||||
 | 
			
		||||
      - name: Setup rpmbuild environment
 | 
			
		||||
@@ -180,10 +180,12 @@ jobs:
 | 
			
		||||
 | 
			
		||||
      - name: Release
 | 
			
		||||
        uses: https://gitea.com/actions/gitea-release-action@v1
 | 
			
		||||
        env:
 | 
			
		||||
            NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
 | 
			
		||||
        with:
 | 
			
		||||
          body_path: changelog.txt
 | 
			
		||||
          token: ${{ env.GITEA_TOKEN }}
 | 
			
		||||
          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:b3809917ab5a7840d42237f5f92d92660cd036bd75ae343e7825e6a24401f166
 | 
			
		||||
      image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
 | 
			
		||||
      volumes:
 | 
			
		||||
        - /usr:/usr-host
 | 
			
		||||
        - /opt:/opt-host
 | 
			
		||||
 
 | 
			
		||||
@@ -11,12 +11,12 @@ repos:
 | 
			
		||||
      - id: check-yaml
 | 
			
		||||
 | 
			
		||||
  - repo: https://github.com/astral-sh/uv-pre-commit
 | 
			
		||||
    rev: 0.8.22
 | 
			
		||||
    rev: 0.9.5
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: uv-lock
 | 
			
		||||
 | 
			
		||||
  - repo: https://github.com/astral-sh/ruff-pre-commit
 | 
			
		||||
    rev: v0.13.2
 | 
			
		||||
    rev: v0.14.3
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: ruff-check
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										36
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						@@ -3,14 +3,42 @@
 | 
			
		||||
Все заметные изменения в этом проекте фиксируются в этом файле.
 | 
			
		||||
Формат основан на [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
 | 
			
		||||
- @Vector_null
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## [0.1.7] - 2025-10-12
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
- Возможность скроллинга библиотеки мышью или пальцем
 | 
			
		||||
- Импорт и экспорт бекапа префикса
 | 
			
		||||
- Диалог для управление Winetricks
 | 
			
		||||
- Кнопки для удаления префикса, wine или proton
 | 
			
		||||
- Виртуальная клавиатура в диалог добавления игры и поиск по библиотеке
 | 
			
		||||
- Все настройки Wine с оригинального PortProton
 | 
			
		||||
- Виртуальная клавиатура в диалог добавления игры и поиск по библиотеке и автоустановках
 | 
			
		||||
- Вкладка автоустановок
 | 
			
		||||
- В заголовке окна теперь отображается версия приложения и хеш коммита если запуск идёт с гита
 | 
			
		||||
 | 
			
		||||
### Changed
 | 
			
		||||
- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
 | 
			
		||||
@@ -22,8 +50,12 @@
 | 
			
		||||
- Исправлено зависание при поиске игр
 | 
			
		||||
- Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity)
 | 
			
		||||
- Исправлена ошибка из-за которой подсказки по управлению снизу и сверху могли не совпадать с друг другом, из-за чего возле вкладок были стрелки клавиатуры, а снизу кнопки геймпада
 | 
			
		||||
- Исправлен выход из полноэкранного режима при отключении геймпада подключённого по USB даже если настройка "Режим полноэкранного отображения приложения при подключении геймпада" выключена
 | 
			
		||||
- При сохранении настроек теперь не меняется размер окна
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
- @wmigor (Igor Akulov)
 | 
			
		||||
- @Vector_null
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										15
									
								
								TODO.md
									
									
									
									
									
								
							
							
						
						@@ -1,6 +1,6 @@
 | 
			
		||||
- [X] Адаптировать структуру проекта для поддержки инструментов сборки
 | 
			
		||||
- [X] Добавить возможность управления с геймпада
 | 
			
		||||
- [ ] Добавить возможность управления с тачскрина
 | 
			
		||||
- [X] Добавить возможность управления с тачскрина (Формально и так есть)
 | 
			
		||||
- [X] Добавить возможность управления с мыши и клавиатуры
 | 
			
		||||
- [X] Добавить систему тем [Документация](documentation/theme_guide)
 | 
			
		||||
- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
 | 
			
		||||
@@ -11,18 +11,18 @@
 | 
			
		||||
- [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800)
 | 
			
		||||
- [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
 | 
			
		||||
- [X] Получать описания и названия игр из базы данных Steam
 | 
			
		||||
- [X] Получать обложки для игр из SteamGridDB или CDN Steam
 | 
			
		||||
- [X] Получать обложки для игр из CDN Steam
 | 
			
		||||
- [X] Оптимизировать работу со Steam API для ускорения времени запуска
 | 
			
		||||
- [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley)
 | 
			
		||||
- [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
 | 
			
		||||
- [X] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
 | 
			
		||||
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
 | 
			
		||||
- [X] Избавиться от вызовов yad
 | 
			
		||||
- [X] Реализовать собственный системный трей вместо использования трея PortProton
 | 
			
		||||
- [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.)
 | 
			
		||||
- [X] Добавить экранную клавиатуру в поиск
 | 
			
		||||
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
 | 
			
		||||
- [X] Добавить индикацию запуска приложения
 | 
			
		||||
- [X] Достигнуть паритета функциональности с Ingame
 | 
			
		||||
- [ ] Достигнуть паритета функциональности с PortProton
 | 
			
		||||
- [ ] Достигнуть паритета функциональности с PortProton (остались настройки игр и обновление скриптов)
 | 
			
		||||
- [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}`
 | 
			
		||||
- [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/)
 | 
			
		||||
- [X] Добавить переводы в переопределения
 | 
			
		||||
@@ -49,7 +49,7 @@
 | 
			
		||||
- [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter)
 | 
			
		||||
- [X] Добавить систему избранного для карточек
 | 
			
		||||
- [X] Заменить все `print` на `logging`
 | 
			
		||||
- [ ] Привести все логи к единому языку
 | 
			
		||||
- [X] Привести все логи к единому языку
 | 
			
		||||
- [X] Уменьшить количество подстановок в переводах
 | 
			
		||||
- [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog)
 | 
			
		||||
- [X] Убрать жёсткую привязку путей к стрелочкам QComboBox в `styles.py`
 | 
			
		||||
@@ -62,7 +62,6 @@
 | 
			
		||||
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
 | 
			
		||||
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
 | 
			
		||||
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
 | 
			
		||||
- [ ] Доделать светлую тему
 | 
			
		||||
- [ ] Добавить подсказки к управлению с геймпада
 | 
			
		||||
- [X] Добавить подсказки к управлению с геймпада
 | 
			
		||||
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
 | 
			
		||||
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры
 | 
			
		||||
 
 | 
			
		||||
@@ -6,11 +6,12 @@ script:
 | 
			
		||||
  - uv pip install --no-cache-dir ../
 | 
			
		||||
  - cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages
 | 
			
		||||
  - cp -r share AppDir/usr
 | 
			
		||||
  - cp -r lib AppDir/usr
 | 
			
		||||
  - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/
 | 
			
		||||
  - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}
 | 
			
		||||
  - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetwork*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*}
 | 
			
		||||
  - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*}
 | 
			
		||||
  - shopt -s extglob
 | 
			
		||||
  - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6DBus*|libQt6Egl*|libQt6Gui*|libQt6Svg*|libQt6Wayland*|libQt6Widgets*|libQt6XcbQpa*|libicudata*|libicui18n*|libicuuc*)
 | 
			
		||||
  - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6DBus*|libQt6Egl*|libQt6Gui*|libQt6Network*|libQt6Svg*|libQt6Wayland*|libQt6Widgets*|libQt6XcbQpa*|libicudata*|libicui18n*|libicuuc*)
 | 
			
		||||
AppDir:
 | 
			
		||||
  path: ./AppDir
 | 
			
		||||
  after_bundle:
 | 
			
		||||
@@ -36,7 +37,7 @@ AppDir:
 | 
			
		||||
    id: ru.linux_gaming.PortProtonQt
 | 
			
		||||
    name: PortProtonQt
 | 
			
		||||
    icon: ru.linux_gaming.PortProtonQt
 | 
			
		||||
    version: 0.1.6
 | 
			
		||||
    version: 0.1.8
 | 
			
		||||
    exec: usr/bin/python3
 | 
			
		||||
    exec_args: "-m portprotonqt.app $@"
 | 
			
		||||
  apt:
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
pkgname=portprotonqt
 | 
			
		||||
pkgver=0.1.6
 | 
			
		||||
pkgver=0.1.8
 | 
			
		||||
pkgrel=1
 | 
			
		||||
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
 | 
			
		||||
arch=('any')
 | 
			
		||||
@@ -20,4 +20,5 @@ package() {
 | 
			
		||||
    cd "$srcdir/PortProtonQt"
 | 
			
		||||
    python -m installer --destdir="$pkgdir" dist/*.whl
 | 
			
		||||
    cp -r build-aux/share "$pkgdir/usr/"
 | 
			
		||||
    cp -r build-aux/lib "$pkgdir/usr/"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -25,4 +25,5 @@ package() {
 | 
			
		||||
    cd "$srcdir/PortProtonQt"
 | 
			
		||||
    python -m installer --destdir="$pkgdir" dist/*.whl
 | 
			
		||||
    cp -r build-aux/share "$pkgdir/usr/"
 | 
			
		||||
    cp -r build-aux/lib "$pkgdir/usr/"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -22,6 +22,7 @@ BuildRequires:  python3-build
 | 
			
		||||
BuildRequires:  pyproject-rpm-macros
 | 
			
		||||
BuildRequires:  python3dist(setuptools)
 | 
			
		||||
BuildRequires:  git
 | 
			
		||||
BuildRequires:  systemd-rpm-macros
 | 
			
		||||
 | 
			
		||||
%description
 | 
			
		||||
%{summary}
 | 
			
		||||
@@ -69,11 +70,13 @@ cd %{oname}
 | 
			
		||||
%pyproject_install
 | 
			
		||||
%pyproject_save_files %{pypi_name}
 | 
			
		||||
cp -r build-aux/share %{buildroot}/usr/
 | 
			
		||||
cp -r build-aux/lib %{buildroot}/usr/
 | 
			
		||||
 | 
			
		||||
%files -n python3-%{pypi_name}-git -f %{pyproject_files}
 | 
			
		||||
%{_bindir}/%{pypi_name}
 | 
			
		||||
%{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
 | 
			
		||||
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
 | 
			
		||||
%{_udevrulesdir}/60-portprotonqt.rules
 | 
			
		||||
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
 | 
			
		||||
%{bash_completions_dir}/portprotonqt
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
%global pypi_name portprotonqt
 | 
			
		||||
%global pypi_version 0.1.6
 | 
			
		||||
%global pypi_version 0.1.8
 | 
			
		||||
%global oname PortProtonQt
 | 
			
		||||
%global _python_no_extras_requires 1
 | 
			
		||||
 | 
			
		||||
@@ -19,6 +19,7 @@ BuildRequires:  python3-build
 | 
			
		||||
BuildRequires:  pyproject-rpm-macros
 | 
			
		||||
BuildRequires:  python3dist(setuptools)
 | 
			
		||||
BuildRequires:  git
 | 
			
		||||
BuildRequires:  systemd-rpm-macros
 | 
			
		||||
 | 
			
		||||
%description
 | 
			
		||||
%{summary}
 | 
			
		||||
@@ -68,11 +69,13 @@ cd %{oname}
 | 
			
		||||
%pyproject_install
 | 
			
		||||
%pyproject_save_files %{pypi_name}
 | 
			
		||||
cp -r build-aux/share %{buildroot}/usr/
 | 
			
		||||
cp -r build-aux/lib %{buildroot}/usr/
 | 
			
		||||
 | 
			
		||||
%files -n python3-%{pypi_name} -f %{pyproject_files}
 | 
			
		||||
%{_bindir}/%{pypi_name}
 | 
			
		||||
%{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
 | 
			
		||||
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
 | 
			
		||||
%{_udevrulesdir}/60-portprotonqt.rules
 | 
			
		||||
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
 | 
			
		||||
%{bash_completions_dir}/portprotonqt
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								build-aux/lib/udev/rules.d/60-portprotonqt.rules
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1 @@
 | 
			
		||||
KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", TAG+="uaccess"
 | 
			
		||||
@@ -1021,7 +1021,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "farlight 84",
 | 
			
		||||
    "status": "Supported"
 | 
			
		||||
    "status": "Denied"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "riders republic",
 | 
			
		||||
@@ -1436,8 +1436,8 @@
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "blue protocol",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
    "normalized_name": "blue protocol star resonance",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "dark and darker",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										12956
									
								
								data/games_appid.json
									
									
									
									
									
								
							
							
						
						@@ -1,4 +1,108 @@
 | 
			
		||||
[
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "split/second",
 | 
			
		||||
    "slug": "split-second"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "warzone 2100",
 | 
			
		||||
    "slug": "warzone-2100"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "foundation",
 | 
			
		||||
    "slug": "foundation"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "земский собор [демо]",
 | 
			
		||||
    "slug": "zemskij-sobor-demo"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "crusader kings 3",
 | 
			
		||||
    "slug": "crusader-kings-3"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "nadir a grimdark deck builder",
 | 
			
		||||
    "slug": "nadir-a-grimdark-deck-builder"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "oriental empires",
 | 
			
		||||
    "slug": "oriental-empires"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "vampire the masquerade bloodlines 2",
 | 
			
		||||
    "slug": "vampire-the-masquerade-bloodlines-2"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "escape from duckov",
 | 
			
		||||
    "slug": "escape-from-duckov"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "xiii",
 | 
			
		||||
    "slug": "xiii"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "saints row 2",
 | 
			
		||||
    "slug": "saints-row-2"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "frozenheim",
 | 
			
		||||
    "slug": "frozenheim"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "saints row (2022)",
 | 
			
		||||
    "slug": "saints-row-2022"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "iron harvest",
 | 
			
		||||
    "slug": "iron-harvest"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "tom clancy's splinter cell blacklist",
 | 
			
		||||
    "slug": "tom-clancys-splinter-cell-blacklist"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "painkiller overdose",
 | 
			
		||||
    "slug": "painkiller-overdose"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "ancestors legacy",
 | 
			
		||||
    "slug": "ancestors-legacy"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "bye sweet carole",
 | 
			
		||||
    "slug": "bye-sweet-carole"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "painkiller black",
 | 
			
		||||
    "slug": "painkiller-black-edition"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "hogwarts legacy",
 | 
			
		||||
    "slug": "hogwarts-legacy"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "active matter",
 | 
			
		||||
    "slug": "active-matter"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "tom clancy's splinter cell",
 | 
			
		||||
    "slug": "tom-clancys-splinter-cell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "sniper ghost warrior",
 | 
			
		||||
    "slug": "sniper-ghost-warrior"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "fate undiscovered realms",
 | 
			
		||||
    "slug": "fate-undiscovered-realms"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "dying light the beast deluxe",
 | 
			
		||||
    "slug": "dying-light-the-beast-deluxe-edition"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "spellforce platinum",
 | 
			
		||||
    "slug": "spellforce-platinum-edition"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "dirt rally 2.0 game of the year",
 | 
			
		||||
    "slug": "dirt-rally-2-0-game-of-the-year-edition"
 | 
			
		||||
@@ -271,10 +375,6 @@
 | 
			
		||||
    "normalized_title": "steins;gate the distant valhalla",
 | 
			
		||||
    "slug": "steins-gate-the-distant-valhalla"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "hogwarts legacy",
 | 
			
		||||
    "slug": "hogwarts-legacy"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "osu!",
 | 
			
		||||
    "slug": "osu"
 | 
			
		||||
 
 | 
			
		||||
@@ -17,17 +17,31 @@ import json
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PySide6DependencyAnalyzer:
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
    def __init__(self, project_root: Path = None):
 | 
			
		||||
        # Системные библиотеки, которые нужно всегда оставлять
 | 
			
		||||
        self.system_libs = {
 | 
			
		||||
            'libQt6XcbQpa', 'libQt6Wayland', 'libQt6Egl',
 | 
			
		||||
            'libicudata', 'libicuuc', 'libicui18n', 'libQt6DBus'
 | 
			
		||||
            'libicudata', 'libicuuc', 'libicui18n', 'libQt6DBus',
 | 
			
		||||
            'libQt6Svg'
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        self.critical_modules = {
 | 
			
		||||
            'QtSvg',
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        self.real_dependencies = {}
 | 
			
		||||
        self.used_modules_code = set()
 | 
			
		||||
        self.used_modules_ldd = set()
 | 
			
		||||
        self.all_required_modules = set()
 | 
			
		||||
        # Определяем корень проекта
 | 
			
		||||
        if project_root is None:
 | 
			
		||||
            # Корень проекта - две директории выше от скрипта
 | 
			
		||||
            self.project_root = Path(__file__).parent.parent
 | 
			
		||||
        else:
 | 
			
		||||
            self.project_root = project_root
 | 
			
		||||
 | 
			
		||||
        self.venv_path = self.project_root / ".venv"
 | 
			
		||||
        self.build_path = self.project_root / "build-aux"
 | 
			
		||||
 | 
			
		||||
    def find_python_files(self, directory: Path) -> List[Path]:
 | 
			
		||||
        """Находит все Python файлы в директории"""
 | 
			
		||||
@@ -44,19 +58,56 @@ class PySide6DependencyAnalyzer:
 | 
			
		||||
        """Находит все PySide6 библиотеки (.so файлы)"""
 | 
			
		||||
        libs = {}
 | 
			
		||||
 | 
			
		||||
        # Поиск в единственной локации
 | 
			
		||||
        search_path = Path("../.venv/lib/python3.10/site-packages/PySide6")
 | 
			
		||||
        print(f"Поиск PySide6 библиотек в: {search_path}")
 | 
			
		||||
        # Ищем venv в корне проекта
 | 
			
		||||
        venv_candidates = [
 | 
			
		||||
            self.venv_path,  # .venv
 | 
			
		||||
            self.project_root / "venv",
 | 
			
		||||
            self.project_root / ".virtualenv",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        pyside6_path = None
 | 
			
		||||
 | 
			
		||||
        # Пробуем найти PySide6 в venv
 | 
			
		||||
        for venv in venv_candidates:
 | 
			
		||||
            if venv.exists():
 | 
			
		||||
                # Ищем Python версию
 | 
			
		||||
                lib_path = venv / "lib"
 | 
			
		||||
                if lib_path.exists():
 | 
			
		||||
                    for python_dir in lib_path.iterdir():
 | 
			
		||||
                        if python_dir.name.startswith('python'):
 | 
			
		||||
                            candidate = python_dir / "site-packages" / "PySide6"
 | 
			
		||||
                            if candidate.exists():
 | 
			
		||||
                                pyside6_path = candidate
 | 
			
		||||
                                print(f"Найден PySide6 в: {candidate}")
 | 
			
		||||
                                break
 | 
			
		||||
                    if pyside6_path:
 | 
			
		||||
                        break
 | 
			
		||||
 | 
			
		||||
        if not pyside6_path:
 | 
			
		||||
            print(f"Предупреждение: PySide6 не найден в venv, проверяем AppDir...")
 | 
			
		||||
            # Если не нашли в venv, пробуем в AppDir
 | 
			
		||||
            if base_path:
 | 
			
		||||
                appdir_candidate = base_path / "AppDir/usr/local/lib"
 | 
			
		||||
                if appdir_candidate.exists():
 | 
			
		||||
                    for python_dir in appdir_candidate.iterdir():
 | 
			
		||||
                        if python_dir.name.startswith('python'):
 | 
			
		||||
                            candidate = python_dir / "dist-packages" / "PySide6"
 | 
			
		||||
                            if candidate.exists():
 | 
			
		||||
                                pyside6_path = candidate
 | 
			
		||||
                                print(f"Найден PySide6 в AppDir: {candidate}")
 | 
			
		||||
                                break
 | 
			
		||||
 | 
			
		||||
        if not pyside6_path:
 | 
			
		||||
            return libs
 | 
			
		||||
 | 
			
		||||
        if search_path.exists():
 | 
			
		||||
        # Ищем .so файлы модулей
 | 
			
		||||
            for so_file in search_path.glob("Qt*.*.so"):
 | 
			
		||||
        for so_file in pyside6_path.glob("Qt*.*.so"):
 | 
			
		||||
            module_name = so_file.stem.split('.')[0]  # QtCore.abi3.so -> QtCore
 | 
			
		||||
            if module_name.startswith('Qt'):
 | 
			
		||||
                libs[module_name] = so_file
 | 
			
		||||
 | 
			
		||||
        # Также ищем в подпапках
 | 
			
		||||
            for subdir in search_path.iterdir():
 | 
			
		||||
        for subdir in pyside6_path.iterdir():
 | 
			
		||||
            if subdir.is_dir() and subdir.name.startswith('Qt'):
 | 
			
		||||
                for so_file in subdir.glob("*.so*"):
 | 
			
		||||
                    if 'Qt' in so_file.name:
 | 
			
		||||
@@ -257,7 +308,9 @@ class PySide6DependencyAnalyzer:
 | 
			
		||||
 | 
			
		||||
        # Модули для удаления
 | 
			
		||||
        if removable_modules:
 | 
			
		||||
            modules_list = ','.join([f"{mod}*" for mod in sorted(removable_modules)])
 | 
			
		||||
            removable_filtered = [m for m in removable_modules if m not in self.critical_modules]
 | 
			
		||||
            if removable_filtered:
 | 
			
		||||
                modules_list = ','.join([f"{mod}*" for mod in sorted(removable_filtered)])
 | 
			
		||||
                cleanup_lines.append(f"  - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{{{modules_list}}}")
 | 
			
		||||
 | 
			
		||||
        # Генерируем команду для удаления нативных библиотек с сохранением нужных
 | 
			
		||||
@@ -276,39 +329,82 @@ class PySide6DependencyAnalyzer:
 | 
			
		||||
            f"  - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!({keep_pattern})"
 | 
			
		||||
        ])
 | 
			
		||||
 | 
			
		||||
        # Заменяем блок очистки в рецепте
 | 
			
		||||
        import re
 | 
			
		||||
 | 
			
		||||
        # Ищем блок "# 5) чистим от ненужных модулей и бинарников" до следующего комментария или до AppDir:
 | 
			
		||||
        pattern = r'(  # 5\) чистим от ненужных модулей и бинарников\n).*?(?=\nAppDir:|\n  # [0-9]+\)|$)'
 | 
			
		||||
        # Ищем весь блок команд очистки PySide6 (от первой rm до AppDir:)
 | 
			
		||||
        # Паттерн: после "  - cp -r lib AppDir/usr\n" идут команды rm, а затем "AppDir:"
 | 
			
		||||
        pattern = r'(  - cp -r lib AppDir/usr\n)((?:  - (?:rm|shopt).*\n)*?)(?=AppDir:)'
 | 
			
		||||
 | 
			
		||||
        new_cleanup_block = "  # 5) чистим от ненужных модулей и бинарников\n" + '\n'.join(cleanup_lines)
 | 
			
		||||
        match = re.search(pattern, recipe_content)
 | 
			
		||||
 | 
			
		||||
        updated_recipe = re.sub(pattern, new_cleanup_block, recipe_content, flags=re.DOTALL)
 | 
			
		||||
        if not match:
 | 
			
		||||
            print("ПРЕДУПРЕЖДЕНИЕ: Не удалось найти блок очистки в рецепте")
 | 
			
		||||
            print("Добавляем команды очистки перед блоком AppDir:")
 | 
			
		||||
 | 
			
		||||
            # Просто вставим команды перед AppDir:
 | 
			
		||||
            appdir_pos = recipe_content.find('AppDir:')
 | 
			
		||||
            if appdir_pos != -1:
 | 
			
		||||
                new_content = (
 | 
			
		||||
                    recipe_content[:appdir_pos] +
 | 
			
		||||
                    '\n'.join(cleanup_lines) + '\n' +
 | 
			
		||||
                    recipe_content[appdir_pos:]
 | 
			
		||||
                )
 | 
			
		||||
                return new_content
 | 
			
		||||
            else:
 | 
			
		||||
                print("ОШИБКА: Не найден блок AppDir: в рецепте")
 | 
			
		||||
                return ""
 | 
			
		||||
 | 
			
		||||
        # Создаем замену - группа 1 (cp -r lib) + новые команды очистки
 | 
			
		||||
        replacement = r'\1' + '\n'.join(cleanup_lines) + '\n'
 | 
			
		||||
 | 
			
		||||
        updated_recipe = re.sub(pattern, replacement, recipe_content, count=1)
 | 
			
		||||
 | 
			
		||||
        return updated_recipe
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def main():
 | 
			
		||||
    parser = argparse.ArgumentParser(description='Анализ зависимостей PySide6 модулей с использованием ldd')
 | 
			
		||||
    parser.add_argument('project_path', help='Путь к проекту для анализа')
 | 
			
		||||
    parser.add_argument('project_path', nargs='?', default='.',
 | 
			
		||||
                        help='Путь к проекту для анализа (по умолчанию: текущая директория)')
 | 
			
		||||
    parser.add_argument('--appdir', help='Путь к AppDir для поиска PySide6 библиотек')
 | 
			
		||||
    parser.add_argument('--output', '-o', help='Путь для сохранения результатов (JSON)')
 | 
			
		||||
    parser.add_argument('--verbose', '-v', action='store_true', help='Подробный вывод')
 | 
			
		||||
    parser.add_argument('--venv', help='Путь к виртуальному окружению (по умолчанию: .venv в корне проекта)')
 | 
			
		||||
 | 
			
		||||
    args = parser.parse_args()
 | 
			
		||||
 | 
			
		||||
    project_path = Path(args.project_path)
 | 
			
		||||
    project_path = Path(args.project_path).resolve()
 | 
			
		||||
    if not project_path.exists():
 | 
			
		||||
        print(f"Ошибка: путь {project_path} не существует")
 | 
			
		||||
        sys.exit(1)
 | 
			
		||||
 | 
			
		||||
    appdir_path = Path(args.appdir) if args.appdir else None
 | 
			
		||||
    appdir_path = Path(args.appdir).resolve() if args.appdir else None
 | 
			
		||||
    if appdir_path and not appdir_path.exists():
 | 
			
		||||
        print(f"Предупреждение: AppDir путь {appdir_path} не существует")
 | 
			
		||||
        appdir_path = None
 | 
			
		||||
 | 
			
		||||
    analyzer = PySide6DependencyAnalyzer()
 | 
			
		||||
    # Определяем корень проекта
 | 
			
		||||
    # Если запущен из подпапки проекта, ищем корень
 | 
			
		||||
    project_root = project_path
 | 
			
		||||
    if (project_path / ".git").exists() or (project_path / "pyproject.toml").exists():
 | 
			
		||||
        project_root = project_path
 | 
			
		||||
    else:
 | 
			
		||||
        # Пытаемся найти корень проекта
 | 
			
		||||
        current = project_path
 | 
			
		||||
        while current != current.parent:
 | 
			
		||||
            if (current / ".git").exists() or (current / "pyproject.toml").exists():
 | 
			
		||||
                project_root = current
 | 
			
		||||
                break
 | 
			
		||||
            current = current.parent
 | 
			
		||||
    
 | 
			
		||||
    print(f"Корень проекта: {project_root}")
 | 
			
		||||
 | 
			
		||||
    analyzer = PySide6DependencyAnalyzer(project_root=project_root)
 | 
			
		||||
    
 | 
			
		||||
    # Если указан custom venv путь
 | 
			
		||||
    if args.venv:
 | 
			
		||||
        analyzer.venv_path = Path(args.venv).resolve()
 | 
			
		||||
        print(f"Использую указанный venv: {analyzer.venv_path}")
 | 
			
		||||
    
 | 
			
		||||
    results = analyzer.analyze_project(project_path, appdir_path)
 | 
			
		||||
 | 
			
		||||
    # Сохраняем в анализатор для генерации команд
 | 
			
		||||
@@ -347,13 +443,13 @@ def main():
 | 
			
		||||
    print(f"\nПотенциальная экономия места: {results['analysis_summary']['space_saving_potential']}")
 | 
			
		||||
 | 
			
		||||
    if args.verbose and results['real_dependencies']:
 | 
			
		||||
        Devlin(f"\nРеальные зависимости (ldd):")
 | 
			
		||||
        print(f"\nРеальные зависимости (ldd):")
 | 
			
		||||
        for module, deps in results['real_dependencies'].items():
 | 
			
		||||
            if deps:
 | 
			
		||||
                print(f"  {module} → {', '.join(deps)}")
 | 
			
		||||
 | 
			
		||||
    # Обновляем AppImage рецепт
 | 
			
		||||
    recipe_path = Path("../build-aux/AppImageBuilder.yml")
 | 
			
		||||
    recipe_path = analyzer.build_path / "AppImageBuilder.yml"
 | 
			
		||||
    if recipe_path.exists():
 | 
			
		||||
        updated_recipe = analyzer.generate_appimage_recipe(results['removable'], recipe_path)
 | 
			
		||||
        if updated_recipe:
 | 
			
		||||
 
 | 
			
		||||
@@ -21,9 +21,9 @@ Current translation status:
 | 
			
		||||
 | 
			
		||||
| Locale | Progress | Translated |
 | 
			
		||||
| :----- | -------: | ---------: |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 233 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 233 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 233 of 233 |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 249 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 249 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 of 249 |
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -21,9 +21,9 @@
 | 
			
		||||
 | 
			
		||||
| Локаль | Прогресс | Переведено |
 | 
			
		||||
| :----- | -------: | ---------: |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 233 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 233 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 233 из 233 |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 249 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 249 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 из 249 |
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,17 +1,46 @@
 | 
			
		||||
import sys
 | 
			
		||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
 | 
			
		||||
import os
 | 
			
		||||
import subprocess
 | 
			
		||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo, QTimer, Qt
 | 
			
		||||
from PySide6.QtWidgets import QApplication
 | 
			
		||||
from PySide6.QtGui import QIcon
 | 
			
		||||
from PySide6.QtNetwork import QLocalServer, QLocalSocket
 | 
			
		||||
 | 
			
		||||
from portprotonqt.main_window import MainWindow
 | 
			
		||||
from portprotonqt.config_utils import save_fullscreen_config
 | 
			
		||||
from portprotonqt.config_utils import (
 | 
			
		||||
    save_fullscreen_config,
 | 
			
		||||
    read_fullscreen_config,
 | 
			
		||||
    get_portproton_start_command
 | 
			
		||||
)
 | 
			
		||||
from portprotonqt.logger import get_logger, setup_logger
 | 
			
		||||
from portprotonqt.cli import parse_args
 | 
			
		||||
 | 
			
		||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
 | 
			
		||||
__app_name__ = "PortProtonQt"
 | 
			
		||||
__app_version__ = "0.1.6"
 | 
			
		||||
__app_version__ = "0.1.8"
 | 
			
		||||
 | 
			
		||||
def get_version():
 | 
			
		||||
    try:
 | 
			
		||||
        commit = subprocess.check_output(
 | 
			
		||||
            ["git", "rev-parse", "--short", "HEAD"],
 | 
			
		||||
            stderr=subprocess.DEVNULL,
 | 
			
		||||
        ).decode("utf-8").strip()
 | 
			
		||||
        return f"{__app_version__} ({commit})"
 | 
			
		||||
    except (subprocess.CalledProcessError, FileNotFoundError, OSError):
 | 
			
		||||
        return __app_version__
 | 
			
		||||
 | 
			
		||||
def main():
 | 
			
		||||
    os.environ["PW_CLI"] = "1"
 | 
			
		||||
    os.environ["PROCESS_LOG"] = "1"
 | 
			
		||||
    os.environ["START_FROM_STEAM"] = "1"
 | 
			
		||||
 | 
			
		||||
    start_sh = get_portproton_start_command()
 | 
			
		||||
 | 
			
		||||
    if start_sh is None:
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    subprocess.run(start_sh + ["cli", "--initial"])
 | 
			
		||||
 | 
			
		||||
    app = QApplication(sys.argv)
 | 
			
		||||
    app.setWindowIcon(QIcon.fromTheme(__app_id__))
 | 
			
		||||
    app.setDesktopFileName(__app_id__)
 | 
			
		||||
@@ -19,40 +48,116 @@ def main():
 | 
			
		||||
    app.setApplicationVersion(__app_version__)
 | 
			
		||||
 | 
			
		||||
    args = parse_args()
 | 
			
		||||
 | 
			
		||||
    # Setup logger with specified debug level
 | 
			
		||||
    setup_logger(args.debug_level)
 | 
			
		||||
 | 
			
		||||
    # Reinitialize logger after setup to ensure it uses the new configuration
 | 
			
		||||
    logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
    # --- Single-instance logic ---
 | 
			
		||||
    server_name = __app_id__
 | 
			
		||||
    socket = QLocalSocket()
 | 
			
		||||
    socket.connectToServer(server_name)
 | 
			
		||||
 | 
			
		||||
    if socket.waitForConnected(200):
 | 
			
		||||
        # Второй экземпляр — передаём команду первому
 | 
			
		||||
        fullscreen = args.fullscreen or read_fullscreen_config()
 | 
			
		||||
        msg = b"show:fullscreen" if fullscreen else b"show"
 | 
			
		||||
        socket.write(msg)
 | 
			
		||||
        socket.flush()
 | 
			
		||||
        socket.waitForBytesWritten(500)
 | 
			
		||||
        socket.disconnectFromServer()
 | 
			
		||||
        logger.info("Restored existing instance from tray")
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # Если старый сокет остался — удалить
 | 
			
		||||
    QLocalServer.removeServer(server_name)
 | 
			
		||||
 | 
			
		||||
    local_server = QLocalServer()
 | 
			
		||||
    if not local_server.listen(server_name):
 | 
			
		||||
        logger.warning(f"Failed to start local server: {local_server.errorString()}")
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # --- Qt translations ---
 | 
			
		||||
    system_locale = QLocale.system()
 | 
			
		||||
    qt_translator = QTranslator()
 | 
			
		||||
    translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
 | 
			
		||||
    if qt_translator.load(system_locale, "qtbase", "_", translations_path):
 | 
			
		||||
        app.installTranslator(qt_translator)
 | 
			
		||||
    else:
 | 
			
		||||
        logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language")
 | 
			
		||||
        logger.warning(
 | 
			
		||||
            f"Qt translations for {system_locale.name()} not found in {translations_path}, using English"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    window = MainWindow(app_name=__app_name__)
 | 
			
		||||
    # --- Main Window ---
 | 
			
		||||
    version = get_version()
 | 
			
		||||
    window = MainWindow(app_name=__app_name__, version=version)
 | 
			
		||||
 | 
			
		||||
    if args.fullscreen:
 | 
			
		||||
        logger.info("Launching in fullscreen mode due to --fullscreen flag")
 | 
			
		||||
    # --- Handle incoming connections ---
 | 
			
		||||
    def handle_new_connection():
 | 
			
		||||
        conn = local_server.nextPendingConnection()
 | 
			
		||||
        if not conn:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        if conn.waitForReadyRead(1000):
 | 
			
		||||
            data = conn.readAll().data()
 | 
			
		||||
            msg = bytes(data).decode("utf-8", errors="ignore")
 | 
			
		||||
            logger.info(f"IPC message received: {msg}")
 | 
			
		||||
 | 
			
		||||
            def restore_window():
 | 
			
		||||
                try:
 | 
			
		||||
                    if msg.startswith("show"):
 | 
			
		||||
                        if hasattr(window, "restore_from_tray"):
 | 
			
		||||
                            window.restore_from_tray()  # type: ignore[attr-defined]
 | 
			
		||||
                        else:
 | 
			
		||||
                            window.showNormal()
 | 
			
		||||
                            window.raise_()
 | 
			
		||||
                            window.activateWindow()
 | 
			
		||||
                            window.setWindowState(
 | 
			
		||||
                                window.windowState() & ~Qt.WindowState.WindowMinimized | Qt.WindowState.WindowActive
 | 
			
		||||
                            )
 | 
			
		||||
 | 
			
		||||
                        if ":fullscreen" in msg:
 | 
			
		||||
                            logger.info("Switching to fullscreen via IPC")
 | 
			
		||||
                            save_fullscreen_config(True)
 | 
			
		||||
                            window.showFullScreen()
 | 
			
		||||
                        else:
 | 
			
		||||
                            logger.info("Switching to normal window via IPC")
 | 
			
		||||
                            save_fullscreen_config(False)
 | 
			
		||||
                            window.showNormal()
 | 
			
		||||
                except Exception as e:
 | 
			
		||||
                    logger.warning(f"Failed to restore window: {e}")
 | 
			
		||||
 | 
			
		||||
            # Выполняем в основном потоке
 | 
			
		||||
            QTimer.singleShot(0, restore_window)
 | 
			
		||||
 | 
			
		||||
        conn.disconnectFromServer()
 | 
			
		||||
 | 
			
		||||
    local_server.newConnection.connect(handle_new_connection)
 | 
			
		||||
 | 
			
		||||
    # --- Initial fullscreen state ---
 | 
			
		||||
    launch_fullscreen = args.fullscreen or read_fullscreen_config()
 | 
			
		||||
    if launch_fullscreen:
 | 
			
		||||
        logger.info(
 | 
			
		||||
            f"Launching in fullscreen mode ({'--fullscreen' if args.fullscreen else 'config'})"
 | 
			
		||||
        )
 | 
			
		||||
        save_fullscreen_config(True)
 | 
			
		||||
        window.showFullScreen()
 | 
			
		||||
    else:
 | 
			
		||||
        logger.info("Launching in normal mode")
 | 
			
		||||
        save_fullscreen_config(False)
 | 
			
		||||
        window.showNormal()
 | 
			
		||||
 | 
			
		||||
    # --- Cleanup ---
 | 
			
		||||
    def cleanup_on_exit():
 | 
			
		||||
        nonlocal window
 | 
			
		||||
        app.aboutToQuit.disconnect()
 | 
			
		||||
        try:
 | 
			
		||||
            local_server.close()
 | 
			
		||||
            QLocalServer.removeServer(server_name)
 | 
			
		||||
            if window:
 | 
			
		||||
                window.close()
 | 
			
		||||
        app.quit()
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.warning(f"Cleanup error: {e}")
 | 
			
		||||
 | 
			
		||||
    app.aboutToQuit.connect(cleanup_on_exit)
 | 
			
		||||
 | 
			
		||||
    window.show()
 | 
			
		||||
 | 
			
		||||
    sys.exit(app.exec())
 | 
			
		||||
 | 
			
		||||
if __name__ == '__main__':
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
    main()
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,13 @@
 | 
			
		||||
import os
 | 
			
		||||
import configparser
 | 
			
		||||
import shutil
 | 
			
		||||
import subprocess
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
_portproton_location = None
 | 
			
		||||
_portproton_start_sh = None
 | 
			
		||||
 | 
			
		||||
# Paths to configuration files
 | 
			
		||||
CONFIG_FILE = os.path.join(
 | 
			
		||||
@@ -101,14 +103,14 @@ def read_file_content(file_path):
 | 
			
		||||
        return f.read().strip()
 | 
			
		||||
 | 
			
		||||
def get_portproton_location():
 | 
			
		||||
    """Returns the path to the PortProton directory.
 | 
			
		||||
    Checks the cached path first. If not set, reads from PORTPROTON_CONFIG_FILE.
 | 
			
		||||
    If the path is invalid, uses the default directory.
 | 
			
		||||
    """
 | 
			
		||||
    """Возвращает путь к PortProton каталогу (строку) или None."""
 | 
			
		||||
    global _portproton_location
 | 
			
		||||
 | 
			
		||||
    if _portproton_location is not None:
 | 
			
		||||
        return _portproton_location
 | 
			
		||||
 | 
			
		||||
    location = None
 | 
			
		||||
 | 
			
		||||
    if os.path.isfile(PORTPROTON_CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            location = read_file_content(PORTPROTON_CONFIG_FILE).strip()
 | 
			
		||||
@@ -116,19 +118,46 @@ def get_portproton_location():
 | 
			
		||||
                _portproton_location = location
 | 
			
		||||
                logger.info(f"PortProton path from configuration: {location}")
 | 
			
		||||
                return _portproton_location
 | 
			
		||||
            logger.warning(f"Invalid PortProton path in configuration: {location}, using default path")
 | 
			
		||||
            logger.warning(f"Invalid PortProton path in configuration: {location}, using defaults")
 | 
			
		||||
        except (OSError, PermissionError) as e:
 | 
			
		||||
            logger.warning(f"Failed to read PortProton configuration file: {e}, using default path")
 | 
			
		||||
            logger.warning(f"Failed to read PortProton configuration file: {e}")
 | 
			
		||||
 | 
			
		||||
    default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
 | 
			
		||||
    if os.path.isdir(default_dir):
 | 
			
		||||
        _portproton_location = default_dir
 | 
			
		||||
        logger.info(f"Using flatpak PortProton directory: {default_dir}")
 | 
			
		||||
    default_flatpak_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
 | 
			
		||||
    if os.path.isdir(default_flatpak_dir):
 | 
			
		||||
        _portproton_location = default_flatpak_dir
 | 
			
		||||
        logger.info(f"Using Flatpak PortProton directory: {default_flatpak_dir}")
 | 
			
		||||
        return _portproton_location
 | 
			
		||||
 | 
			
		||||
    logger.warning("PortProton configuration and flatpak directory not found")
 | 
			
		||||
    logger.warning("PortProton configuration and Flatpak directory not found")
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
def get_portproton_start_command():
 | 
			
		||||
    """Возвращает список команд для запуска PortProton (start.sh или flatpak run)."""
 | 
			
		||||
    portproton_path = get_portproton_location()
 | 
			
		||||
    if not portproton_path:
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        result = subprocess.run(
 | 
			
		||||
            ["flatpak", "list"],
 | 
			
		||||
            capture_output=True,
 | 
			
		||||
            text=True,
 | 
			
		||||
            check=False
 | 
			
		||||
        )
 | 
			
		||||
        if "ru.linux_gaming.PortProton" in result.stdout:
 | 
			
		||||
            logger.info("Detected Flatpak installation")
 | 
			
		||||
            return ["flatpak", "run", "ru.linux_gaming.PortProton"]
 | 
			
		||||
    except Exception:
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    start_sh_path = os.path.join(portproton_path, "data", "scripts", "start.sh")
 | 
			
		||||
    if os.path.exists(start_sh_path):
 | 
			
		||||
        return [start_sh_path]
 | 
			
		||||
 | 
			
		||||
    logger.warning("Neither flatpak nor start.sh found for PortProton")
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def parse_desktop_entry(file_path):
 | 
			
		||||
    """Reads and parses a .desktop file using configparser.
 | 
			
		||||
    Returns None if the [Desktop Entry] section is missing.
 | 
			
		||||
@@ -177,6 +206,26 @@ def save_card_size(card_width):
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_auto_card_size():
 | 
			
		||||
    """Reads the card size (width) for Auto Install from the [Cards] section.
 | 
			
		||||
    Returns 250 if the parameter is not set.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Cards") or not cp.has_option("Cards", "auto_card_width"):
 | 
			
		||||
        save_auto_card_size(250)
 | 
			
		||||
        return 250
 | 
			
		||||
    return cp.getint("Cards", "auto_card_width", fallback=250)
 | 
			
		||||
 | 
			
		||||
def save_auto_card_size(card_width):
 | 
			
		||||
    """Saves the card size (width) for Auto Install to the [Cards] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Cards" not in cp:
 | 
			
		||||
        cp["Cards"] = {}
 | 
			
		||||
    cp["Cards"]["auto_card_width"] = str(card_width)
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def read_sort_method():
 | 
			
		||||
    """Reads the sort method from the [Games] section.
 | 
			
		||||
    Returns 'last_launch' if the parameter is not set.
 | 
			
		||||
@@ -259,6 +308,25 @@ def save_rumble_config(rumble_enabled):
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_gamepad_type():
 | 
			
		||||
    """Reads the gamepad type from the [Gamepad] section.
 | 
			
		||||
    Returns 'xbox' if the parameter is missing.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "type"):
 | 
			
		||||
        save_gamepad_type("xbox")
 | 
			
		||||
        return "xbox"
 | 
			
		||||
    return cp.get("Gamepad", "type", fallback="xbox").lower()
 | 
			
		||||
 | 
			
		||||
def save_gamepad_type(gpad_type):
 | 
			
		||||
    """Saves the gamepad type to the [Gamepad] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Gamepad" not in cp:
 | 
			
		||||
        cp["Gamepad"] = {}
 | 
			
		||||
    cp["Gamepad"]["type"] = gpad_type
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def ensure_default_proxy_config():
 | 
			
		||||
    """Ensures the [Proxy] section exists in the configuration file.
 | 
			
		||||
    Creates it with empty values if missing.
 | 
			
		||||
@@ -408,3 +476,22 @@ def save_favorite_folders(folders):
 | 
			
		||||
    cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_minimize_to_tray():
 | 
			
		||||
    """Reads the minimize-to-tray setting from the [Display] section.
 | 
			
		||||
    Returns True if the parameter is missing (default: minimize to tray).
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "minimize_to_tray"):
 | 
			
		||||
        save_minimize_to_tray(True)
 | 
			
		||||
        return True
 | 
			
		||||
    return cp.getboolean("Display", "minimize_to_tray", fallback=True)
 | 
			
		||||
 | 
			
		||||
def save_minimize_to_tray(minimize_to_tray):
 | 
			
		||||
    """Saves the minimize-to-tray setting to the [Display] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Display" not in cp:
 | 
			
		||||
        cp["Display"] = {}
 | 
			
		||||
    cp["Display"]["minimize_to_tray"] = str(minimize_to_tray)
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 
 | 
			
		||||
@@ -11,7 +11,7 @@ from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplicati
 | 
			
		||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
 | 
			
		||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
 | 
			
		||||
from portprotonqt.localization import _
 | 
			
		||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders
 | 
			
		||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders, get_portproton_start_command
 | 
			
		||||
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
 | 
			
		||||
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam
 | 
			
		||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
 | 
			
		||||
@@ -406,16 +406,7 @@ class ContextMenuManager:
 | 
			
		||||
                )
 | 
			
		||||
                return
 | 
			
		||||
            # Construct EGS launch command
 | 
			
		||||
            wrapper = "flatpak run ru.linux_gaming.PortProton"
 | 
			
		||||
            start_sh_path = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
 | 
			
		||||
            if self.portproton_location and ".var" not in self.portproton_location:
 | 
			
		||||
                wrapper = start_sh_path
 | 
			
		||||
                if not os.path.exists(start_sh_path):
 | 
			
		||||
                    self.signals.show_warning_dialog.emit(
 | 
			
		||||
                        _("Error"),
 | 
			
		||||
                        _("start.sh not found at {path}").format(path=start_sh_path)
 | 
			
		||||
                    )
 | 
			
		||||
                    return
 | 
			
		||||
            wrapper = get_portproton_start_command()
 | 
			
		||||
            exec_line = f'"{self.legendary_path}" launch {game_card.appid} --no-wine --wrapper "env START_FROM_STEAM=1 {wrapper}"'
 | 
			
		||||
        else:
 | 
			
		||||
            exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
 | 
			
		||||
 
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 634 KiB After Width: | Height: | Size: 634 KiB  | 
| 
		 Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 315 KiB  | 
| 
		 Before Width: | Height: | Size: 978 KiB After Width: | Height: | Size: 978 KiB  | 
| 
		 Before Width: | Height: | Size: 650 KiB After Width: | Height: | Size: 650 KiB  | 
| 
		 Before Width: | Height: | Size: 391 KiB After Width: | Height: | Size: 391 KiB  | 
| 
		 Before Width: | Height: | Size: 710 KiB After Width: | Height: | Size: 710 KiB  | 
| 
		 Before Width: | Height: | Size: 627 KiB After Width: | Height: | Size: 627 KiB  | 
@@ -2,12 +2,11 @@ import os
 | 
			
		||||
import tempfile
 | 
			
		||||
import re
 | 
			
		||||
from typing import cast, TYPE_CHECKING
 | 
			
		||||
from PySide6.QtGui import QPixmap, QIcon, QTextCursor
 | 
			
		||||
from PySide6.QtGui import QPixmap, QIcon, QTextCursor, QColor
 | 
			
		||||
from PySide6.QtWidgets import (
 | 
			
		||||
    QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller,
 | 
			
		||||
    QTabWidget, QTableWidget, QHeaderView, QMessageBox, QTableWidgetItem, QTextEdit, QAbstractItemView, QStackedWidget
 | 
			
		||||
    QTabWidget, QTableWidget, QHeaderView, QMessageBox, QTableWidgetItem, QTextEdit, QAbstractItemView, QStackedWidget, QComboBox, QLineEdit
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot, QProcess, QProcessEnvironment
 | 
			
		||||
from icoextract import IconExtractor, IconExtractorError
 | 
			
		||||
from PIL import Image
 | 
			
		||||
@@ -91,6 +90,130 @@ def generate_thumbnail(inputfile, outfile, size=128, force_resize=True):
 | 
			
		||||
        logger.error(f"Ошибка при сохранении миниатюры: {e}")
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
def create_dialog_hints_widget(theme, main_window, input_manager, context='default'):
 | 
			
		||||
    """
 | 
			
		||||
    Common function to create hints widget for all dialogs.
 | 
			
		||||
    Uses main_window for get_button_icon/get_nav_icon, input_manager for gamepad detection.
 | 
			
		||||
    """
 | 
			
		||||
    theme_manager = ThemeManager()
 | 
			
		||||
    current_theme_name = read_theme_from_config()
 | 
			
		||||
 | 
			
		||||
    hintsWidget = QWidget()
 | 
			
		||||
    hintsWidget.setStyleSheet(theme.STATUS_BAR_STYLE)
 | 
			
		||||
    hintsLayout = QHBoxLayout(hintsWidget)
 | 
			
		||||
    hintsLayout.setContentsMargins(10, 0, 10, 0)
 | 
			
		||||
    hintsLayout.setSpacing(20)
 | 
			
		||||
 | 
			
		||||
    dialog_actions = []
 | 
			
		||||
 | 
			
		||||
    # Context-specific actions (gamepad only, no keyboard)
 | 
			
		||||
    if context == 'file_explorer':
 | 
			
		||||
        dialog_actions = [
 | 
			
		||||
            ("confirm", _("Open")),        # A / Cross
 | 
			
		||||
            ("add_game", _("Select Dir")), # X / Triangle
 | 
			
		||||
            ("prev_dir", _("Prev Dir")),   # Y / Square
 | 
			
		||||
            ("back", _("Cancel")),         # B / Circle
 | 
			
		||||
            ("context_menu", _("Menu")),   # Start / Options
 | 
			
		||||
        ]
 | 
			
		||||
    elif context == 'winetricks':
 | 
			
		||||
        dialog_actions = [
 | 
			
		||||
            ("confirm", _("Toggle")),         # A / Cross
 | 
			
		||||
            ("add_game", _("Install")),       # X / Triangle
 | 
			
		||||
            ("prev_dir", _("Force Install")), # Y / Square
 | 
			
		||||
            ("back", _("Cancel")),            # B / Circle
 | 
			
		||||
            ("prev_tab", _("Prev Tab")),      # LB / L1
 | 
			
		||||
            ("next_tab", _("Next Tab")),      # RB / R1
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    hints_labels = []  # Store for updates (returned for class storage)
 | 
			
		||||
 | 
			
		||||
    def make_hint(icon_name, text, action=None):
 | 
			
		||||
        container = QWidget()
 | 
			
		||||
        hlayout = QHBoxLayout(container)
 | 
			
		||||
        hlayout.setContentsMargins(0, 5, 0, 0)
 | 
			
		||||
        hlayout.setSpacing(6)
 | 
			
		||||
 | 
			
		||||
        icon_label = QLabel()
 | 
			
		||||
        icon_label.setFixedSize(26, 26)
 | 
			
		||||
        icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
 | 
			
		||||
 | 
			
		||||
        pixmap = QPixmap()
 | 
			
		||||
        icon_path = theme_manager.get_theme_image(icon_name, current_theme_name)
 | 
			
		||||
        if icon_path:
 | 
			
		||||
            pixmap.load(str(icon_path))
 | 
			
		||||
        if not pixmap.isNull():
 | 
			
		||||
            icon_label.setPixmap(pixmap.scaled(26, 26, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
 | 
			
		||||
 | 
			
		||||
        hlayout.addWidget(icon_label)
 | 
			
		||||
 | 
			
		||||
        text_label = QLabel(text)
 | 
			
		||||
        text_label.setStyleSheet(theme.LAST_LAUNCH_VALUE_STYLE)
 | 
			
		||||
        text_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
 | 
			
		||||
        hlayout.addWidget(text_label)
 | 
			
		||||
 | 
			
		||||
        # Initially hidden; show only if gamepad connected
 | 
			
		||||
        container.setVisible(False)
 | 
			
		||||
        hints_labels.append((container, icon_label, action))
 | 
			
		||||
 | 
			
		||||
        hintsLayout.addWidget(container)
 | 
			
		||||
 | 
			
		||||
    # Add gamepad hints only
 | 
			
		||||
    for action, text in dialog_actions:
 | 
			
		||||
        make_hint("placeholder", text, action)
 | 
			
		||||
 | 
			
		||||
    hintsLayout.addStretch()
 | 
			
		||||
 | 
			
		||||
    # Return widget and labels for class storage
 | 
			
		||||
    return hintsWidget, hints_labels
 | 
			
		||||
 | 
			
		||||
def update_dialog_hints(hints_labels, main_window, input_manager, theme_manager, current_theme_name):
 | 
			
		||||
    """
 | 
			
		||||
    Common function to update hints for any dialog.
 | 
			
		||||
    """
 | 
			
		||||
    if not input_manager or not main_window:
 | 
			
		||||
        # Hide all if no input_manager or main_window
 | 
			
		||||
        for container, _, _ in hints_labels:
 | 
			
		||||
            container.setVisible(False)
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    is_gamepad = input_manager.gamepad is not None
 | 
			
		||||
    if not is_gamepad:
 | 
			
		||||
        # Hide all hints if no gamepad
 | 
			
		||||
        for container, _, _ in hints_labels:
 | 
			
		||||
            container.setVisible(False)
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    gtype = input_manager.gamepad_type
 | 
			
		||||
    gamepad_actions = ['confirm', 'back', 'context_menu', 'add_game', 'prev_dir', 'prev_tab', 'next_tab']
 | 
			
		||||
 | 
			
		||||
    for container, icon_label, action in hints_labels:
 | 
			
		||||
        if action and action in gamepad_actions:
 | 
			
		||||
            container.setVisible(True)
 | 
			
		||||
            # Update icon using main_window methods
 | 
			
		||||
            if action in ['confirm', 'back', 'context_menu', 'add_game', 'prev_dir']:
 | 
			
		||||
                icon_name = main_window.get_button_icon(action, gtype)
 | 
			
		||||
            else:  # only prev_tab/next_tab (treat as nav)
 | 
			
		||||
                direction = 'left' if action == 'prev_tab' else 'right'
 | 
			
		||||
                icon_name = main_window.get_nav_icon(direction, gtype)
 | 
			
		||||
            icon_path = theme_manager.get_theme_image(icon_name, current_theme_name)
 | 
			
		||||
            pixmap = QPixmap()
 | 
			
		||||
            if icon_path:
 | 
			
		||||
                pixmap.load(str(icon_path))
 | 
			
		||||
            if not pixmap.isNull():
 | 
			
		||||
                icon_label.setPixmap(pixmap.scaled(
 | 
			
		||||
                    26, 26,
 | 
			
		||||
                    Qt.AspectRatioMode.KeepAspectRatio,
 | 
			
		||||
                    Qt.TransformationMode.SmoothTransformation
 | 
			
		||||
                ))
 | 
			
		||||
            else:
 | 
			
		||||
                # Fallback to placeholder
 | 
			
		||||
                placeholder = theme_manager.get_theme_image("placeholder", current_theme_name)
 | 
			
		||||
                if placeholder:
 | 
			
		||||
                    pixmap.load(str(placeholder))
 | 
			
		||||
                    icon_label.setPixmap(pixmap.scaled(26, 26, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
 | 
			
		||||
        else:
 | 
			
		||||
            container.setVisible(False)
 | 
			
		||||
 | 
			
		||||
class FileSelectedSignal(QObject):
 | 
			
		||||
    file_selected = Signal(str)  # Сигнал с путем к выбранному файлу
 | 
			
		||||
 | 
			
		||||
@@ -185,6 +308,7 @@ class FileExplorer(QDialog):
 | 
			
		||||
        self.initial_path = initial_path  # Store initial path if provided
 | 
			
		||||
        self.thumbnail_cache = {}  # Cache for loaded thumbnails
 | 
			
		||||
        self.pending_thumbnails = set()  # Track files pending thumbnail loading
 | 
			
		||||
        self.main_window = None  # Add reference to MainWindow
 | 
			
		||||
        self.setup_ui()
 | 
			
		||||
 | 
			
		||||
        # Window settings
 | 
			
		||||
@@ -198,6 +322,7 @@ class FileExplorer(QDialog):
 | 
			
		||||
        while parent:
 | 
			
		||||
            if hasattr(parent, 'input_manager'):
 | 
			
		||||
                self.input_manager = cast("MainWindow", parent).input_manager
 | 
			
		||||
                self.main_window = parent
 | 
			
		||||
            if hasattr(parent, 'context_menu_manager'):
 | 
			
		||||
                self.context_menu_manager = cast("MainWindow", parent).context_menu_manager
 | 
			
		||||
            parent = parent.parent()
 | 
			
		||||
@@ -214,6 +339,17 @@ class FileExplorer(QDialog):
 | 
			
		||||
            self.current_path = os.path.expanduser("~")  # Fallback to home if initial path is invalid
 | 
			
		||||
        self.update_file_list()
 | 
			
		||||
 | 
			
		||||
        # Create hints widget using common function
 | 
			
		||||
        self.current_theme_name = read_theme_from_config()
 | 
			
		||||
        self.hints_widget, self.hints_labels = create_dialog_hints_widget(self.theme, self.main_window, self.input_manager, context='file_explorer')
 | 
			
		||||
        self.main_layout.addWidget(self.hints_widget)
 | 
			
		||||
 | 
			
		||||
        # Connect signals
 | 
			
		||||
        if self.input_manager:
 | 
			
		||||
            self.input_manager.button_event.connect(lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name))
 | 
			
		||||
            self.input_manager.dpad_moved.connect(lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name))
 | 
			
		||||
            update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
 | 
			
		||||
 | 
			
		||||
    class ThumbnailLoader(QRunnable):
 | 
			
		||||
        """Class for asynchronous thumbnail loading in a separate thread."""
 | 
			
		||||
        class Signals(QObject):
 | 
			
		||||
@@ -897,8 +1033,8 @@ class AddGameDialog(QDialog):
 | 
			
		||||
        """Обработчик выбора файла в FileExplorer"""
 | 
			
		||||
        self.exeEdit.setText(file_path)
 | 
			
		||||
        self.last_exe_path = file_path  # Update last selected exe path
 | 
			
		||||
        if not self.edit_mode:
 | 
			
		||||
            # Автоматически заполняем имя игры, если не в режиме редактирования
 | 
			
		||||
        if not self.edit_mode and not self.nameEdit.text().strip():
 | 
			
		||||
            # Автоматически заполняем имя игры, если не в режиме редактирования или если оно не введено вручную
 | 
			
		||||
            game_name = os.path.splitext(os.path.basename(file_path))[0]
 | 
			
		||||
            self.nameEdit.setText(game_name)
 | 
			
		||||
 | 
			
		||||
@@ -1037,8 +1173,6 @@ Icon={icon_path}
 | 
			
		||||
        return desktop_entry, desktop_path
 | 
			
		||||
 | 
			
		||||
class WinetricksDialog(QDialog):
 | 
			
		||||
    """Dialog for managing Winetricks components in a prefix."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, parent=None, theme=None, prefix_path: str | None = None, wine_use: str | None = None):
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
        self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
 | 
			
		||||
@@ -1071,6 +1205,36 @@ class WinetricksDialog(QDialog):
 | 
			
		||||
        self.setup_ui()
 | 
			
		||||
        self.load_lists()
 | 
			
		||||
 | 
			
		||||
        # Find input_manager and main_window
 | 
			
		||||
        self.input_manager = None
 | 
			
		||||
        self.main_window = None
 | 
			
		||||
        parent = self.parent()
 | 
			
		||||
        while parent:
 | 
			
		||||
            if hasattr(parent, 'input_manager'):
 | 
			
		||||
                self.input_manager = cast("MainWindow", parent).input_manager
 | 
			
		||||
                self.main_window = parent
 | 
			
		||||
            parent = parent.parent()
 | 
			
		||||
 | 
			
		||||
        self.current_theme_name = read_theme_from_config()
 | 
			
		||||
 | 
			
		||||
        # Enable Winetricks-specific mode
 | 
			
		||||
        if self.input_manager:
 | 
			
		||||
            self.input_manager.enable_winetricks_mode(self)
 | 
			
		||||
 | 
			
		||||
        # Create hints widget using common function
 | 
			
		||||
        self.hints_widget, self.hints_labels = create_dialog_hints_widget(self.theme, self.main_window, self.input_manager, context='winetricks')
 | 
			
		||||
        self.main_layout.addWidget(self.hints_widget)
 | 
			
		||||
 | 
			
		||||
        # Connect signals (use self.theme_manager)
 | 
			
		||||
        if self.input_manager:
 | 
			
		||||
            self.input_manager.button_event.connect(
 | 
			
		||||
                lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
 | 
			
		||||
            )
 | 
			
		||||
            self.input_manager.dpad_moved.connect(
 | 
			
		||||
                lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
 | 
			
		||||
            )
 | 
			
		||||
            update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
 | 
			
		||||
 | 
			
		||||
    def update_winetricks(self):
 | 
			
		||||
        """Update the winetricks script."""
 | 
			
		||||
        if not self.downloader.has_internet():
 | 
			
		||||
@@ -1143,15 +1307,15 @@ class WinetricksDialog(QDialog):
 | 
			
		||||
 | 
			
		||||
    def setup_ui(self):
 | 
			
		||||
        """Set up the user interface with tabs and tables."""
 | 
			
		||||
        main_layout = QVBoxLayout(self)
 | 
			
		||||
        main_layout.setContentsMargins(10, 10, 10, 10)
 | 
			
		||||
        main_layout.setSpacing(10)
 | 
			
		||||
        self.main_layout = QVBoxLayout(self)
 | 
			
		||||
        self.main_layout.setContentsMargins(10, 10, 10, 10)
 | 
			
		||||
        self.main_layout.setSpacing(10)
 | 
			
		||||
 | 
			
		||||
        # Log output
 | 
			
		||||
        self.log_output = QTextEdit()
 | 
			
		||||
        self.log_output.setReadOnly(True)
 | 
			
		||||
        self.log_output.setStyleSheet(self.theme.WINETRICKS_LOG_STYLE)
 | 
			
		||||
        main_layout.addWidget(self.log_output)
 | 
			
		||||
        self.main_layout.addWidget(self.log_output)
 | 
			
		||||
 | 
			
		||||
        # Tab widget
 | 
			
		||||
        self.tab_widget = QTabWidget()
 | 
			
		||||
@@ -1258,7 +1422,7 @@ class WinetricksDialog(QDialog):
 | 
			
		||||
            "settings": self.settings_container
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        main_layout.addWidget(self.tab_widget)
 | 
			
		||||
        self.main_layout.addWidget(self.tab_widget)
 | 
			
		||||
 | 
			
		||||
        # Buttons
 | 
			
		||||
        button_layout = QHBoxLayout()
 | 
			
		||||
@@ -1272,7 +1436,7 @@ class WinetricksDialog(QDialog):
 | 
			
		||||
        button_layout.addWidget(self.cancel_button)
 | 
			
		||||
        button_layout.addWidget(self.force_button)
 | 
			
		||||
        button_layout.addWidget(self.install_button)
 | 
			
		||||
        main_layout.addLayout(button_layout)
 | 
			
		||||
        self.main_layout.addLayout(button_layout)
 | 
			
		||||
 | 
			
		||||
        self.cancel_button.clicked.connect(self.reject)
 | 
			
		||||
        self.force_button.clicked.connect(lambda: self.install_selected(force=True))
 | 
			
		||||
@@ -1497,3 +1661,575 @@ class WinetricksDialog(QDialog):
 | 
			
		||||
        """Добавляет в лог."""
 | 
			
		||||
        self.log_output.append(message)
 | 
			
		||||
        self.log_output.moveCursor(QTextCursor.MoveOperation.End)
 | 
			
		||||
 | 
			
		||||
    def closeEvent(self, event):
 | 
			
		||||
        """Disable mode on close."""
 | 
			
		||||
        if self.input_manager:
 | 
			
		||||
            self.input_manager.disable_winetricks_mode()
 | 
			
		||||
        super().closeEvent(event)
 | 
			
		||||
 | 
			
		||||
    def reject(self):
 | 
			
		||||
        """Disable mode on reject."""
 | 
			
		||||
        if self.input_manager:
 | 
			
		||||
            self.input_manager.disable_winetricks_mode()
 | 
			
		||||
        super().reject()
 | 
			
		||||
 | 
			
		||||
class ExeSettingsDialog(QDialog):
 | 
			
		||||
    def __init__(self, parent=None, theme=None, exe_path=None):
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
        self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
 | 
			
		||||
        self.exe_path = exe_path
 | 
			
		||||
        if not self.exe_path:
 | 
			
		||||
            return
 | 
			
		||||
        self.portproton_path = get_portproton_location()
 | 
			
		||||
        if self.portproton_path is None:
 | 
			
		||||
            logger.error("PortProton location not found")
 | 
			
		||||
            return
 | 
			
		||||
        base_path = os.path.join(self.portproton_path, "data")
 | 
			
		||||
        self.start_sh = [os.path.join(base_path, "scripts", "start.sh")]
 | 
			
		||||
 | 
			
		||||
        self.current_settings = {}
 | 
			
		||||
        self.value_widgets = {}
 | 
			
		||||
        self.original_values = {}
 | 
			
		||||
        self.advanced_widgets = {}
 | 
			
		||||
        self.original_display_values = {}
 | 
			
		||||
        self.available_keys = set()
 | 
			
		||||
        self.blocked_keys = set()
 | 
			
		||||
        self.numa_nodes = {}
 | 
			
		||||
        self.is_amd = False
 | 
			
		||||
        self.locale_options = []
 | 
			
		||||
        self.logical_core_options = []
 | 
			
		||||
        self.amd_vulkan_drivers = []
 | 
			
		||||
 | 
			
		||||
        self.setWindowTitle(_("Exe Settings"))
 | 
			
		||||
        self.setModal(True)
 | 
			
		||||
        self.resize(900, 600)
 | 
			
		||||
        self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
 | 
			
		||||
 | 
			
		||||
        self.init_toggle_settings()
 | 
			
		||||
        self.setup_ui()
 | 
			
		||||
 | 
			
		||||
        # Find input_manager and main_window
 | 
			
		||||
        self.input_manager = None
 | 
			
		||||
        self.main_window = None
 | 
			
		||||
        parent = self.parent()
 | 
			
		||||
        while parent:
 | 
			
		||||
            if hasattr(parent, 'input_manager'):
 | 
			
		||||
                self.input_manager = cast("MainWindow", parent).input_manager
 | 
			
		||||
                self.main_window = parent
 | 
			
		||||
            parent = parent.parent()
 | 
			
		||||
 | 
			
		||||
        self.current_theme_name = read_theme_from_config()
 | 
			
		||||
 | 
			
		||||
        # Load current settings (includes list-db)
 | 
			
		||||
        self.load_current_settings()
 | 
			
		||||
 | 
			
		||||
    def _get_process_args(self, subcommand_args):
 | 
			
		||||
        """Get the full arguments for QProcess.start, handling flatpak separator."""
 | 
			
		||||
        if self.start_sh[0] == "flatpak":
 | 
			
		||||
            return self.start_sh[1:] + ["--"] + subcommand_args
 | 
			
		||||
        else:
 | 
			
		||||
            return self.start_sh + subcommand_args
 | 
			
		||||
 | 
			
		||||
    def init_toggle_settings(self):
 | 
			
		||||
        """Initialize predefined toggle settings with descriptions."""
 | 
			
		||||
        self.toggle_settings = {
 | 
			
		||||
            'PW_MANGOHUD': _("Using FPS and system load monitoring (Turns on and off by the key combination - right Shift + F12)"),
 | 
			
		||||
            'PW_MANGOHUD_USER_CONF': _("Forced use of MANGOHUD system settings (GOverlay, etc.)"),
 | 
			
		||||
            'PW_VKBASALT': _("Enable vkBasalt by default to improve graphics in games running on Vulkan. (The HOME hotkey disables vkbasalt)"),
 | 
			
		||||
            'PW_VKBASALT_USER_CONF': _("Forced use of VKBASALT system settings (GOverlay, etc.)"),
 | 
			
		||||
            'PW_DGVOODOO2': _("Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, DirectDraw 1-7, Direct3D 2-9) on all 3D API."),
 | 
			
		||||
            'PW_GAMESCOPE': _("Super + F : Toggle fullscreen\nSuper + N : Toggle nearest neighbour filtering\nSuper + U : Toggle FSR upscaling\nSuper + Y : Toggle NIS upscaling\nSuper + I : Increase FSR sharpness by 1\nSuper + O : Decrease FSR sharpness by 1\nSuper + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\nSuper + G : Toggle keyboard grab\nSuper + C : Update clipboard"),
 | 
			
		||||
            'PW_USE_ESYNC': _("Enable in-process synchronization primitives based on eventfd."),
 | 
			
		||||
            'PW_USE_FSYNC': _("Enable futex-based in-process synchronization primitives."),
 | 
			
		||||
            'PW_USE_NTSYNC': _("Enable in-process synchronization via the Linux ntsync driver."),
 | 
			
		||||
            'PW_USE_RAY_TRACING': _("Enable vkd3d support - Ray Tracing"),
 | 
			
		||||
            'PW_USE_NVAPI_AND_DLSS': _("Enable DLSS on supported NVIDIA graphics cards"),
 | 
			
		||||
            'PW_USE_OPTISCALER': _("Enable OptiScaler (replacement upscaler / frame generator)"),
 | 
			
		||||
            'PW_USE_LS_FRAME_GEN': _("Enable Lossless Scaling frame generation (experimental)"),
 | 
			
		||||
            'PW_WINE_FULLSCREEN_FSR': _("FSR upscaling in fullscreen with ProtonGE below native resolution"),
 | 
			
		||||
            'PW_HIDE_NVIDIA_GPU': _("Disguise all NVIDIA GPU features"),
 | 
			
		||||
            'PW_VIRTUAL_DESKTOP': _("Run the application in WINE virtual desktop"),
 | 
			
		||||
            'PW_USE_TERMINAL': _("Run the application in a terminal"),
 | 
			
		||||
            'PW_GUI_DISABLED_CS': _("Disable startup mode and WINE version selector window"),
 | 
			
		||||
            'PW_USE_GAMEMODE': _("Use system GameMode for performance optimization"),
 | 
			
		||||
            'PW_USE_D3D_EXTRAS': _("Enable forced use of third-party DirectX libraries"),
 | 
			
		||||
            'PW_FIX_VIDEO_IN_GAME': _("Fix pink-tinted video playback in some games"),
 | 
			
		||||
            'PW_REDUCE_PULSE_LATENCY': _("Reduce PulseAudio latency to fix intermittent sound"),
 | 
			
		||||
            'PW_USE_US_LAYOUT': _("Force US keyboard layout"),
 | 
			
		||||
            'PW_USE_GSTREAMER': _("Use GStreamer for in-game clips (WMF support)"),
 | 
			
		||||
            'PW_USE_SHADER_CACHE': _("Use WINE shader caching"),
 | 
			
		||||
            'PW_USE_WINE_DXGI': _("Force use of built-in DXGI library"),
 | 
			
		||||
            'PW_USE_EAC_AND_BE': _("Enable Easy Anti-Cheat and BattlEye runtimes"),
 | 
			
		||||
            'PW_USE_SYSTEM_VK_LAYERS': _("Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"),
 | 
			
		||||
            'PW_USE_OBS_VKCAPTURE': _("Enable OBS Studio capture via obs-vkcapture"),
 | 
			
		||||
            'PW_DISABLE_COMPOSITING': _("Disable desktop compositing for performance"),
 | 
			
		||||
            'PW_USE_RUNTIME': _("Use container launch mode (recommended default)"),
 | 
			
		||||
            'PW_DINPUT_PROTOCOL': _("Force DirectInput protocol instead of XInput"),
 | 
			
		||||
            'PW_USE_NATIVE_WAYLAND': _("Enable experimental native Wayland support"),
 | 
			
		||||
            'PW_USE_DXVK_HDR': _("Enable HDR settings under native Wayland"),
 | 
			
		||||
            'PW_USE_GALLIUM_ZINK': _("Use Gallium Zink (OpenGL via Vulkan)"),
 | 
			
		||||
            'PW_USE_GALLIUM_NINE': _("Use Gallium Nine (native DirectX 9 for Mesa)"),
 | 
			
		||||
            'PW_USE_WINED3D_VULKAN': _("Use WineD3D Vulkan backend (Damavand)"),
 | 
			
		||||
            'PW_USE_SUPPLIED_DXVK_VKD3D': _("Use bundled dxvk/vkd3d from Wine/Proton"),
 | 
			
		||||
            'PW_USE_SAREK_ASYNC': _("Use async dxvk-sarek (experimental)")
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    def setup_ui(self):
 | 
			
		||||
        """Set up the user interface."""
 | 
			
		||||
        self.main_layout = QVBoxLayout(self)
 | 
			
		||||
        self.main_layout.setContentsMargins(10, 10, 10, 10)
 | 
			
		||||
        self.main_layout.setSpacing(10)
 | 
			
		||||
 | 
			
		||||
        # Tab widget
 | 
			
		||||
        self.tab_widget = QTabWidget()
 | 
			
		||||
        self.main_tab = QWidget()
 | 
			
		||||
        self.main_tab_layout = QVBoxLayout(self.main_tab)
 | 
			
		||||
        self.advanced_tab = QWidget()
 | 
			
		||||
        self.advanced_tab_layout = QVBoxLayout(self.advanced_tab)
 | 
			
		||||
 | 
			
		||||
        self.tab_widget.addTab(self.main_tab, _("Main"))
 | 
			
		||||
        self.tab_widget.addTab(self.advanced_tab, _("Advanced"))
 | 
			
		||||
 | 
			
		||||
        # Таблица настроек
 | 
			
		||||
        self.settings_table = QTableWidget()
 | 
			
		||||
        self.settings_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
        self.settings_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
 | 
			
		||||
        self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
 | 
			
		||||
        self.settings_table.setColumnCount(3)
 | 
			
		||||
        self.settings_table.setHorizontalHeaderLabels([_("Setting"), _("Value"), _("Description")])
 | 
			
		||||
        self.settings_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
 | 
			
		||||
        self.settings_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
 | 
			
		||||
        self.settings_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
 | 
			
		||||
        self.settings_table.horizontalHeader().resizeSection(1, 100)
 | 
			
		||||
        self.settings_table.setWordWrap(True)
 | 
			
		||||
        self.settings_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
 | 
			
		||||
        self.settings_table.setTextElideMode(Qt.TextElideMode.ElideNone)
 | 
			
		||||
        self.settings_table.setStyleSheet(self.theme.WINETRICKS_TABBLE_STYLE)
 | 
			
		||||
        self.main_tab_layout.addWidget(self.settings_table)
 | 
			
		||||
 | 
			
		||||
        # Таблица Advanced
 | 
			
		||||
        self.advanced_table = QTableWidget()
 | 
			
		||||
        self.advanced_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
        self.advanced_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
 | 
			
		||||
        self.advanced_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
 | 
			
		||||
        self.advanced_table.setColumnCount(3)
 | 
			
		||||
        self.advanced_table.setHorizontalHeaderLabels([_("Setting"), _("Value"), _("Description")])
 | 
			
		||||
        self.advanced_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
 | 
			
		||||
        self.advanced_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
 | 
			
		||||
        self.advanced_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
 | 
			
		||||
        self.advanced_table.horizontalHeader().resizeSection(1, 200)
 | 
			
		||||
        self.advanced_table.setWordWrap(True)
 | 
			
		||||
        self.advanced_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
 | 
			
		||||
        self.advanced_table.setTextElideMode(Qt.TextElideMode.ElideNone)
 | 
			
		||||
        self.advanced_table.setStyleSheet(self.theme.WINETRICKS_TABBLE_STYLE)
 | 
			
		||||
        self.advanced_tab_layout.addWidget(self.advanced_table)
 | 
			
		||||
 | 
			
		||||
        self.main_layout.addWidget(self.tab_widget)
 | 
			
		||||
 | 
			
		||||
        # Кнопки
 | 
			
		||||
        button_layout = QHBoxLayout()
 | 
			
		||||
        self.apply_button = AutoSizeButton(_("Apply"), icon=ThemeManager().get_icon("apply"))
 | 
			
		||||
        self.cancel_button = AutoSizeButton(_("Cancel"), icon=ThemeManager().get_icon("cancel"))
 | 
			
		||||
        self.apply_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
 | 
			
		||||
        self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
 | 
			
		||||
        button_layout.addWidget(self.apply_button)
 | 
			
		||||
        button_layout.addWidget(self.cancel_button)
 | 
			
		||||
        self.main_layout.addLayout(button_layout)
 | 
			
		||||
 | 
			
		||||
        self.apply_button.clicked.connect(self.apply_changes)
 | 
			
		||||
        self.cancel_button.clicked.connect(self.reject)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def load_current_settings(self):
 | 
			
		||||
        """Load available toggles first, then current settings."""
 | 
			
		||||
        process = QProcess(self)
 | 
			
		||||
        process.finished.connect(self.on_list_db_finished)
 | 
			
		||||
        process.start(self.start_sh[0], ["cli", "--list-db"])
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def on_list_db_finished(self, exit_code, exit_status):
 | 
			
		||||
        """Handle --list-db output and extract available keys and system info."""
 | 
			
		||||
        process = cast(QProcess, self.sender())
 | 
			
		||||
        self.available_keys = set()
 | 
			
		||||
        self.blocked_keys = set()
 | 
			
		||||
        if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
 | 
			
		||||
            output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore')
 | 
			
		||||
            lines = output.splitlines()
 | 
			
		||||
            self.numa_nodes = {}
 | 
			
		||||
            self.is_amd = False
 | 
			
		||||
            self.logical_core_options = []
 | 
			
		||||
            self.locale_options = []
 | 
			
		||||
            self.amd_vulkan_drivers = []
 | 
			
		||||
            for line in lines:
 | 
			
		||||
                line_stripped = line.strip()
 | 
			
		||||
                if not line_stripped:
 | 
			
		||||
                    continue
 | 
			
		||||
                if re.match(r'^[A-Z_0-9]+=[^=]+$', line_stripped) and not line_stripped.startswith('PW_'):
 | 
			
		||||
                    # System info
 | 
			
		||||
                    k, v = line_stripped.split('=', 1)
 | 
			
		||||
                    if k.startswith('NUMA_NODE_'):
 | 
			
		||||
                        node_id = k[10:]
 | 
			
		||||
                        self.numa_nodes[node_id] = v
 | 
			
		||||
                    elif k == 'IS_AMD':
 | 
			
		||||
                        self.is_amd = v.lower() == 'true'
 | 
			
		||||
                    elif k == 'LOGICAL_CORE_OPTIONS':
 | 
			
		||||
                        self.logical_core_options = v.split('!') if v else []
 | 
			
		||||
                    elif k == 'LOCALE_LIST':
 | 
			
		||||
                        self.locale_options = v.split('!') if v else []
 | 
			
		||||
                    elif k == 'AMD_VULKAN_DRIVER_LIST':
 | 
			
		||||
                        self.amd_vulkan_drivers = v.split('!') if v else []
 | 
			
		||||
                    continue
 | 
			
		||||
                if line_stripped.startswith('PW_'):
 | 
			
		||||
                    parts = line_stripped.split(maxsplit=1)
 | 
			
		||||
                    key = parts[0]
 | 
			
		||||
                    self.available_keys.add(key)
 | 
			
		||||
                    if len(parts) > 1 and 'blocked' in parts[1]:
 | 
			
		||||
                        self.blocked_keys.add(key)
 | 
			
		||||
 | 
			
		||||
            # Показываем только пересечение
 | 
			
		||||
            self.available_keys &= set(self.toggle_settings.keys())
 | 
			
		||||
 | 
			
		||||
        # Загружаем текущие настройки
 | 
			
		||||
        process = QProcess(self)
 | 
			
		||||
        process.finished.connect(self.on_show_ppdb_finished)
 | 
			
		||||
        process.start(self.start_sh[0], ["cli", "--show-ppdb", f"{self.exe_path}.ppdb"])
 | 
			
		||||
 | 
			
		||||
    def on_show_ppdb_finished(self, exit_code, exit_status):
 | 
			
		||||
        """Handle --show-ppdb output."""
 | 
			
		||||
        process = cast(QProcess, self.sender())
 | 
			
		||||
        if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
 | 
			
		||||
            # Fallback to defaults if load fails
 | 
			
		||||
            for key in self.toggle_settings:
 | 
			
		||||
                self.current_settings[key] = '0'
 | 
			
		||||
            for adv_key in ['PW_WINDOWS_VER', 'WINEDLLOVERRIDES', 'LAUNCH_PARAMETERS',
 | 
			
		||||
                            'PW_WINE_CPU_TOPOLOGY', 'PW_MESA_GL_VERSION_OVERRIDE',
 | 
			
		||||
                            'PW_VKD3D_FEATURE_LEVEL', 'PW_LOCALE_SELECT',
 | 
			
		||||
                            'PW_MESA_VK_WSI_PRESENT_MODE', 'PW_AMD_VULKAN_USE',
 | 
			
		||||
                            'PW_CPU_NUMA_NODE_INDEX']:
 | 
			
		||||
                self.current_settings[adv_key] = 'disabled' if 'TOPOLOGY' in adv_key or 'SELECT' in adv_key or 'MODE' in adv_key or 'LEVEL' in adv_key or 'GL_VERSION' in adv_key or 'NUMA' in adv_key else ''
 | 
			
		||||
        else:
 | 
			
		||||
            output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore').strip()
 | 
			
		||||
            self.current_settings = {}
 | 
			
		||||
            for line in output.split('\n'):
 | 
			
		||||
                line_stripped = line.strip()
 | 
			
		||||
                if '=' in line_stripped:
 | 
			
		||||
                    # Parse all KEY=VALUE lines, not just specific prefixes, to catch more
 | 
			
		||||
                    try:
 | 
			
		||||
                        key, val = line_stripped.split('=', 1)
 | 
			
		||||
                        if key in self.toggle_settings or key in ['PW_WINDOWS_VER', 'WINEDLLOVERRIDES', 'LAUNCH_PARAMETERS',
 | 
			
		||||
                                                                 'PW_WINE_CPU_TOPOLOGY', 'PW_MESA_GL_VERSION_OVERRIDE',
 | 
			
		||||
                                                                 'PW_VKD3D_FEATURE_LEVEL', 'PW_LOCALE_SELECT',
 | 
			
		||||
                                                                 'PW_MESA_VK_WSI_PRESENT_MODE', 'PW_AMD_VULKAN_USE',
 | 
			
		||||
                                                                 'PW_CPU_NUMA_NODE_INDEX', 'PW_TASKSET_SLR']:
 | 
			
		||||
                            self.current_settings[key] = val
 | 
			
		||||
                    except ValueError:
 | 
			
		||||
                        continue
 | 
			
		||||
 | 
			
		||||
        # Force blocked settings to '0'
 | 
			
		||||
        for key in self.blocked_keys:
 | 
			
		||||
            self.current_settings[key] = '0'
 | 
			
		||||
 | 
			
		||||
        self.original_values = self.current_settings.copy()
 | 
			
		||||
        for key in set(self.toggle_settings.keys()):
 | 
			
		||||
            self.original_values.setdefault(key, '0')
 | 
			
		||||
 | 
			
		||||
        self.populate_table()
 | 
			
		||||
        self.populate_advanced()
 | 
			
		||||
 | 
			
		||||
    def populate_table(self):
 | 
			
		||||
        """Populate the table with settings that are available in both lists."""
 | 
			
		||||
        self.settings_table.setRowCount(0)
 | 
			
		||||
        self.value_widgets.clear()
 | 
			
		||||
        self.settings_table.verticalHeader().setVisible(False)
 | 
			
		||||
 | 
			
		||||
        visible_keys = sorted(self.available_keys) if self.available_keys else sorted(self.toggle_settings.keys())
 | 
			
		||||
 | 
			
		||||
        for toggle in visible_keys:
 | 
			
		||||
            description = self.toggle_settings.get(toggle)
 | 
			
		||||
            if not description:
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            row = self.settings_table.rowCount()
 | 
			
		||||
            self.settings_table.insertRow(row)
 | 
			
		||||
 | 
			
		||||
            name_item = QTableWidgetItem(toggle)
 | 
			
		||||
            name_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
 | 
			
		||||
 | 
			
		||||
            current_val = self.current_settings.get(toggle, '0')
 | 
			
		||||
            is_blocked = toggle in self.blocked_keys
 | 
			
		||||
            checkbox = QTableWidgetItem()
 | 
			
		||||
            checkbox.setFlags(checkbox.flags() | Qt.ItemFlag.ItemIsUserCheckable)
 | 
			
		||||
            check_state = Qt.CheckState.Checked if current_val == '1' and not is_blocked else Qt.CheckState.Unchecked
 | 
			
		||||
            checkbox.setCheckState(check_state)
 | 
			
		||||
            checkbox.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
 | 
			
		||||
            if is_blocked:
 | 
			
		||||
                checkbox.setFlags(checkbox.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
 | 
			
		||||
                checkbox.setBackground(QColor(240, 240, 240))
 | 
			
		||||
                name_item.setForeground(QColor(128, 128, 128))
 | 
			
		||||
            self.settings_table.setItem(row, 1, checkbox)
 | 
			
		||||
 | 
			
		||||
            desc_item = QTableWidgetItem(description)
 | 
			
		||||
            desc_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
 | 
			
		||||
            desc_item.setToolTip(description)
 | 
			
		||||
            desc_item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
 | 
			
		||||
            if is_blocked:
 | 
			
		||||
                desc_item.setForeground(QColor(128, 128, 128))
 | 
			
		||||
            self.settings_table.setItem(row, 2, desc_item)
 | 
			
		||||
 | 
			
		||||
            self.settings_table.setItem(row, 0, name_item)
 | 
			
		||||
            self.value_widgets[(row, 1)] = checkbox
 | 
			
		||||
 | 
			
		||||
        self.settings_table.resizeRowsToContents()
 | 
			
		||||
        if self.settings_table.rowCount() > 0:
 | 
			
		||||
            self.settings_table.setCurrentCell(0, 0)
 | 
			
		||||
            self.settings_table.setFocus(Qt.FocusReason.OtherFocusReason)
 | 
			
		||||
 | 
			
		||||
    def populate_advanced(self):
 | 
			
		||||
        """Populate the advanced tab with table format."""
 | 
			
		||||
        self.advanced_table.setRowCount(0)
 | 
			
		||||
        self.advanced_widgets.clear()
 | 
			
		||||
        self.original_display_values = {}
 | 
			
		||||
        self.advanced_table.verticalHeader().setVisible(False)
 | 
			
		||||
 | 
			
		||||
        current = self.current_settings
 | 
			
		||||
        disabled_text = _('disabled')
 | 
			
		||||
 | 
			
		||||
        # Define advanced settings configuration
 | 
			
		||||
        advanced_settings = []
 | 
			
		||||
 | 
			
		||||
        # 1. Windows version
 | 
			
		||||
        advanced_settings.append({
 | 
			
		||||
            'key': 'PW_WINDOWS_VER',
 | 
			
		||||
            'name': _("Windows version"),
 | 
			
		||||
            'description': _("Changing the WINDOWS emulation version may be required to run older games. WINDOWS versions below 10 do not support new games with DirectX 12"),
 | 
			
		||||
            'type': 'combo',
 | 
			
		||||
            'options': ['11', '10', '7', 'XP'],
 | 
			
		||||
            'default': '10'
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
        # 2. Forced to use/disable libraries
 | 
			
		||||
        advanced_settings.append({
 | 
			
		||||
            'key': 'WINEDLLOVERRIDES',
 | 
			
		||||
            'name': _("DLL Overrides"),
 | 
			
		||||
            'description': _("Forced to use/disable the library only for the given application.\n\nA brief instruction:\n* libraries are written WITHOUT the .dll file extension\n* libraries are separated by semicolons - ;\n* library=n - use the WINDOWS (third-party) library\n* library=b - use WINE (built-in) library\n* library=n,b - use WINDOWS library and then WINE\n* library=b,n - use WINE library and then WINDOWS\n* library= - disable the use of this library\n\nExample: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"),
 | 
			
		||||
            'type': 'text',
 | 
			
		||||
            'default': ''
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
        # 3. Launch arguments
 | 
			
		||||
        advanced_settings.append({
 | 
			
		||||
            'key': 'LAUNCH_PARAMETERS',
 | 
			
		||||
            'name': _("Launch Arguments"),
 | 
			
		||||
            'description': _("Adding an argument after the .exe file, just like you would add an argument in a shortcut on a WINDOWS system.\n\nExample: -dx11 -skipintro 1"),
 | 
			
		||||
            'type': 'text',
 | 
			
		||||
            'default': ''
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
        # 4. CPU cores limit
 | 
			
		||||
        advanced_settings.append({
 | 
			
		||||
            'key': 'PW_WINE_CPU_TOPOLOGY',
 | 
			
		||||
            'name': _("CPU Cores Limit"),
 | 
			
		||||
            'description': _("Limiting the number of CPU cores is useful for Unity games (It is recommended to set the value equal to 8)"),
 | 
			
		||||
            'type': 'combo',
 | 
			
		||||
            'options': [disabled_text] + self.logical_core_options,
 | 
			
		||||
            'default': disabled_text
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
        # 5. OpenGL version
 | 
			
		||||
        advanced_settings.append({
 | 
			
		||||
            'key': 'PW_MESA_GL_VERSION_OVERRIDE',
 | 
			
		||||
            'name': _("OpenGL Version"),
 | 
			
		||||
            'description': _("You can select the required OpenGL version, some games require a forced Compatibility Profile (COMP)."),
 | 
			
		||||
            'type': 'combo',
 | 
			
		||||
            'options': [disabled_text, '4.6COMPAT', '4.5COMPAT', '4.3COMPAT', '4.1COMPAT', '3.3COMPAT', '3.2COMPAT'],
 | 
			
		||||
            'default': disabled_text
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
        # 6. VKD3D feature level
 | 
			
		||||
        advanced_settings.append({
 | 
			
		||||
            'key': 'PW_VKD3D_FEATURE_LEVEL',
 | 
			
		||||
            'name': _("VKD3D Feature Level"),
 | 
			
		||||
            'description': _("You can set a forced feature level VKD3D for games on DirectX12"),
 | 
			
		||||
            'type': 'combo',
 | 
			
		||||
            'options': [disabled_text, '12_2', '12_1', '12_0', '11_1', '11_0'],
 | 
			
		||||
            'default': disabled_text
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
        # 7. Locale
 | 
			
		||||
        advanced_settings.append({
 | 
			
		||||
            'key': 'PW_LOCALE_SELECT',
 | 
			
		||||
            'name': _("Locale"),
 | 
			
		||||
            'description': _("Force certain locale for an app. Fixes encoding issues in legacy software"),
 | 
			
		||||
            'type': 'combo',
 | 
			
		||||
            'options': [disabled_text] + self.locale_options,
 | 
			
		||||
            'default': disabled_text
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
        # 8. Present mode
 | 
			
		||||
        advanced_settings.append({
 | 
			
		||||
            'key': 'PW_MESA_VK_WSI_PRESENT_MODE',
 | 
			
		||||
            'name': _("Window Mode"),
 | 
			
		||||
            'description': _("Window mode (for Vulkan and OpenGL):\nfifo - First in, first out. Limits the frame rate + no tearing. (VSync)\nimmediate - Unlimited frame rate + tearing.\nmailbox - Triple buffering. Unlimited frame rate + no tearing.\nrelaxed - Same as fifo but allows tearing when below the monitors refresh rate."),
 | 
			
		||||
            'type': 'combo',
 | 
			
		||||
            'options': [disabled_text, 'fifo', 'immediate', 'mailbox', 'relaxed'],
 | 
			
		||||
            'default': disabled_text
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
        # 9. AMD Vulkan (always show, block if not applicable)
 | 
			
		||||
        amd_options = [disabled_text] + self.amd_vulkan_drivers if self.is_amd and self.amd_vulkan_drivers else [disabled_text]
 | 
			
		||||
        advanced_settings.append({
 | 
			
		||||
            'key': 'PW_AMD_VULKAN_USE',
 | 
			
		||||
            'name': _("AMD Vulkan Driver"),
 | 
			
		||||
            'description': _("Select needed AMD vulkan implementation. Choosing which implementation of vulkan will be used to run the game"),
 | 
			
		||||
            'type': 'combo',
 | 
			
		||||
            'options': amd_options,
 | 
			
		||||
            'default': disabled_text
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
        # 10. NUMA node (always show if numa_nodes exist, block if <=1)
 | 
			
		||||
        numa_ids = sorted(self.numa_nodes.keys())
 | 
			
		||||
        numa_options = [disabled_text] + numa_ids if len(numa_ids) > 1 else [disabled_text]
 | 
			
		||||
        advanced_settings.append({
 | 
			
		||||
            'key': 'PW_CPU_NUMA_NODE_INDEX',
 | 
			
		||||
            'name': _("NUMA Node"),
 | 
			
		||||
            'description': _("NUMA node for CPU affinity. In multi-core systems, CPUs are split into NUMA nodes, each with its own local memory and cores. Binding a game to a single node reduces memory-access latency and limits costly core-to-core switches."),
 | 
			
		||||
            'type': 'combo',
 | 
			
		||||
            'options': numa_options,
 | 
			
		||||
            'default': disabled_text
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
        # Populate table
 | 
			
		||||
        for setting in advanced_settings:
 | 
			
		||||
            row = self.advanced_table.rowCount()
 | 
			
		||||
            self.advanced_table.insertRow(row)
 | 
			
		||||
 | 
			
		||||
            # Name column
 | 
			
		||||
            name_item = QTableWidgetItem(setting['name'])
 | 
			
		||||
            name_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
 | 
			
		||||
            self.advanced_table.setItem(row, 0, name_item)
 | 
			
		||||
 | 
			
		||||
            # Value column (widget)
 | 
			
		||||
            if setting['type'] == 'combo':
 | 
			
		||||
                combo = QComboBox()
 | 
			
		||||
                combo.addItems(setting['options'])
 | 
			
		||||
 | 
			
		||||
                # Get current value
 | 
			
		||||
                current_raw = current.get(setting['key'], setting['default'])
 | 
			
		||||
                if setting['key'] == 'PW_WINE_CPU_TOPOLOGY':
 | 
			
		||||
                    current_val = disabled_text if current_raw == 'disabled' else (current_raw.split(':')[0] if isinstance(current_raw, str) and ':' in current_raw else current_raw)
 | 
			
		||||
                elif setting['key'] == 'PW_AMD_VULKAN_USE':
 | 
			
		||||
                    current_val = disabled_text if not current_raw or current_raw == '' else current_raw
 | 
			
		||||
                else:
 | 
			
		||||
                    current_val = disabled_text if current_raw == 'disabled' else current_raw
 | 
			
		||||
 | 
			
		||||
                if current_val not in setting['options']:
 | 
			
		||||
                    combo.addItem(current_val)
 | 
			
		||||
                combo.setCurrentText(current_val)
 | 
			
		||||
 | 
			
		||||
                # Block if only disabled option
 | 
			
		||||
                if len(setting['options']) == 1:
 | 
			
		||||
                    combo.setEnabled(False)
 | 
			
		||||
 | 
			
		||||
                self.advanced_table.setCellWidget(row, 1, combo)
 | 
			
		||||
                self.advanced_widgets[setting['key']] = combo
 | 
			
		||||
                self.original_display_values[setting['key']] = current_val
 | 
			
		||||
 | 
			
		||||
            elif setting['type'] == 'text':
 | 
			
		||||
                line_edit = QLineEdit()
 | 
			
		||||
                current_val = current.get(setting['key'], setting['default'])
 | 
			
		||||
                line_edit.setText(current_val)
 | 
			
		||||
 | 
			
		||||
                self.advanced_table.setCellWidget(row, 1, line_edit)
 | 
			
		||||
                self.advanced_widgets[setting['key']] = line_edit
 | 
			
		||||
                self.original_display_values[setting['key']] = current_val
 | 
			
		||||
 | 
			
		||||
            # Description column
 | 
			
		||||
            desc_item = QTableWidgetItem(setting['description'])
 | 
			
		||||
            desc_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
 | 
			
		||||
            desc_item.setToolTip(setting['description'])
 | 
			
		||||
            desc_item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
 | 
			
		||||
            self.advanced_table.setItem(row, 2, desc_item)
 | 
			
		||||
 | 
			
		||||
        # Make sure QLineEdit and QComboBox look consistent
 | 
			
		||||
        self.advanced_table.setStyleSheet("""
 | 
			
		||||
        QComboBox, QLineEdit {
 | 
			
		||||
            padding: 3px 6px;
 | 
			
		||||
            min-height: 26px;
 | 
			
		||||
        }
 | 
			
		||||
        QComboBox::drop-down {
 | 
			
		||||
            subcontrol-origin: padding;
 | 
			
		||||
            subcontrol-position: top right;
 | 
			
		||||
            width: 18px;
 | 
			
		||||
        }
 | 
			
		||||
        QTextEdit {
 | 
			
		||||
            border-radius: 4px;
 | 
			
		||||
            padding: 4px;
 | 
			
		||||
        }
 | 
			
		||||
        """)
 | 
			
		||||
 | 
			
		||||
    def apply_changes(self):
 | 
			
		||||
        """Apply changes by collecting diffs from both main and advanced tabs."""
 | 
			
		||||
        changes = []
 | 
			
		||||
 | 
			
		||||
        for key, orig_val in self.original_values.items():
 | 
			
		||||
            if key in self.blocked_keys:
 | 
			
		||||
                continue  # Skip blocked keys
 | 
			
		||||
            row = -1
 | 
			
		||||
            for r in range(self.settings_table.rowCount()):
 | 
			
		||||
                item0 = self.settings_table.item(r, 0)
 | 
			
		||||
                if item0 and item0.text() == key:
 | 
			
		||||
                    row = r
 | 
			
		||||
                    break
 | 
			
		||||
            if row == -1:
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            item = self.settings_table.item(row, 1)
 | 
			
		||||
            if not item:
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            new_val = '1' if item.checkState() == Qt.CheckState.Checked else '0'
 | 
			
		||||
            if new_val != orig_val:
 | 
			
		||||
                changes.append(f"{key}={new_val}")
 | 
			
		||||
 | 
			
		||||
        for key, widget in self.advanced_widgets.items():
 | 
			
		||||
            orig_val = self.original_display_values.get(key, '')
 | 
			
		||||
            if isinstance(widget, QComboBox):
 | 
			
		||||
                new_val = widget.currentText()
 | 
			
		||||
                if new_val.lower() == _('disabled').lower():
 | 
			
		||||
                    new_val = 'disabled'
 | 
			
		||||
            elif isinstance(widget, QLineEdit):
 | 
			
		||||
                new_val = widget.text().strip()
 | 
			
		||||
            else:
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            if new_val != orig_val:
 | 
			
		||||
                changes.append(f"{key}={new_val}")
 | 
			
		||||
 | 
			
		||||
        if not changes:
 | 
			
		||||
            QMessageBox.information(self, _("Info"), _("No changes to apply."))
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        process = QProcess(self)
 | 
			
		||||
        process.finished.connect(self.on_edit_db_finished)
 | 
			
		||||
        args = ["cli", "--edit-db", self.exe_path] + changes
 | 
			
		||||
        process.start(self.start_sh[0], args)
 | 
			
		||||
        self.apply_button.setEnabled(False)
 | 
			
		||||
 | 
			
		||||
    def on_edit_db_finished(self, exit_code, exit_status):
 | 
			
		||||
        """Handle --edit-db output."""
 | 
			
		||||
        process = cast(QProcess, self.sender())
 | 
			
		||||
        self.apply_button.setEnabled(True)
 | 
			
		||||
        if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
 | 
			
		||||
            error_output = bytes(process.readAllStandardError().data()).decode('utf-8', 'ignore')
 | 
			
		||||
            QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
 | 
			
		||||
            logger.error(f"Failed to apply changes: {error_output}")
 | 
			
		||||
        else:
 | 
			
		||||
            self.load_current_settings()
 | 
			
		||||
            QMessageBox.information(self, _("Success"), _("Settings updated successfully."))
 | 
			
		||||
 | 
			
		||||
    def closeEvent(self, event):
 | 
			
		||||
        super().closeEvent(event)
 | 
			
		||||
 | 
			
		||||
    def reject(self):
 | 
			
		||||
        super().reject()
 | 
			
		||||
 
 | 
			
		||||
@@ -13,7 +13,7 @@ from portprotonqt.localization import get_egs_language, _
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.image_utils import load_pixmap_async
 | 
			
		||||
from portprotonqt.time_utils import parse_playtime_file, format_playtime, get_last_launch, get_last_launch_timestamp
 | 
			
		||||
from portprotonqt.config_utils import get_portproton_location
 | 
			
		||||
from portprotonqt.config_utils import get_portproton_location, get_portproton_start_command
 | 
			
		||||
from portprotonqt.steam_api import (
 | 
			
		||||
    get_weanticheatyet_status_async, get_steam_apps_and_index_async, get_protondb_tier_async,
 | 
			
		||||
    search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail, call_steam_api
 | 
			
		||||
@@ -254,14 +254,7 @@ def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callba
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # Determine wrapper
 | 
			
		||||
    wrapper = "flatpak run ru.linux_gaming.PortProton"
 | 
			
		||||
    start_sh_path = os.path.join(portproton_dir, "data", "scripts", "start.sh")
 | 
			
		||||
    if portproton_dir is not None and ".var" not in portproton_dir:
 | 
			
		||||
        wrapper = start_sh_path
 | 
			
		||||
        if not os.path.exists(start_sh_path):
 | 
			
		||||
            logger.error(f"start.sh not found at {start_sh_path}")
 | 
			
		||||
            callback((False, f"start.sh not found at {start_sh_path}"))
 | 
			
		||||
            return
 | 
			
		||||
    wrapper = get_portproton_start_command()
 | 
			
		||||
 | 
			
		||||
    # Create launch script
 | 
			
		||||
    steam_scripts_dir = os.path.join(portproton_dir, "steam_scripts")
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
from PySide6.QtGui import QPainter, QColor, QDesktopServices
 | 
			
		||||
from PySide6.QtCore import Signal, Property, Qt, QUrl
 | 
			
		||||
from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer
 | 
			
		||||
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
from portprotonqt.image_utils import load_pixmap_async, round_corners
 | 
			
		||||
@@ -404,6 +404,13 @@ class GameCard(QFrame):
 | 
			
		||||
            self.favoriteLabel.setText("☆")
 | 
			
		||||
        self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
 | 
			
		||||
 | 
			
		||||
        parent = self.parent()
 | 
			
		||||
        while parent:
 | 
			
		||||
            if hasattr(parent, 'game_library_manager'):
 | 
			
		||||
                QTimer.singleShot(0, parent.game_library_manager.update_game_grid) # type: ignore[attr-defined]
 | 
			
		||||
                break
 | 
			
		||||
            parent = parent.parent()
 | 
			
		||||
 | 
			
		||||
    def toggle_favorite(self):
 | 
			
		||||
        favorites = read_favorites()
 | 
			
		||||
        if self.is_favorite:
 | 
			
		||||
 
 | 
			
		||||
@@ -33,6 +33,7 @@ class MainWindowProtocol(Protocol):
 | 
			
		||||
    # Required attributes
 | 
			
		||||
    searchEdit: CustomLineEdit
 | 
			
		||||
    _last_card_width: int
 | 
			
		||||
    card_width: int
 | 
			
		||||
    current_hovered_card: GameCard | None
 | 
			
		||||
    current_focused_card: GameCard | None
 | 
			
		||||
    gamesListWidget: QWidget | None
 | 
			
		||||
@@ -128,6 +129,8 @@ class GameLibraryManager:
 | 
			
		||||
        self.card_width = self.sizeSlider.value()
 | 
			
		||||
        self.sizeSlider.setToolTip(f"{self.card_width} px")
 | 
			
		||||
        save_card_size(self.card_width)
 | 
			
		||||
        self.main_window.card_width = self.card_width
 | 
			
		||||
        self.main_window._last_card_width = self.card_width
 | 
			
		||||
        for card in self.game_card_cache.values():
 | 
			
		||||
            card.update_card_size(self.card_width)
 | 
			
		||||
        self.update_game_grid()
 | 
			
		||||
@@ -217,6 +220,16 @@ class GameLibraryManager:
 | 
			
		||||
        else:
 | 
			
		||||
            self._update_game_grid_immediate()
 | 
			
		||||
 | 
			
		||||
    def force_update_cards_library(self):
 | 
			
		||||
        if self.gamesListWidget and self.gamesListLayout:
 | 
			
		||||
            self.gamesListLayout.invalidate()
 | 
			
		||||
            self.gamesListWidget.updateGeometry()
 | 
			
		||||
            widget = self.gamesListWidget
 | 
			
		||||
            QTimer.singleShot(0, lambda: (
 | 
			
		||||
                widget.adjustSize(),
 | 
			
		||||
                widget.updateGeometry()
 | 
			
		||||
            ))
 | 
			
		||||
 | 
			
		||||
    def _update_game_grid_immediate(self):
 | 
			
		||||
        """Updates the game grid with the provided or current game list."""
 | 
			
		||||
        if self.gamesListLayout is None or self.gamesListWidget is None:
 | 
			
		||||
@@ -346,6 +359,8 @@ class GameLibraryManager:
 | 
			
		||||
                self.gamesListWidget.updateGeometry()
 | 
			
		||||
                self.main_window._last_card_width = self.card_width
 | 
			
		||||
 | 
			
		||||
                self.force_update_cards_library()
 | 
			
		||||
 | 
			
		||||
        self.is_filtering = False  # Reset flag in any case
 | 
			
		||||
 | 
			
		||||
    def _apply_filter_visibility(self, search_text: str):
 | 
			
		||||
 
 | 
			
		||||
@@ -83,6 +83,43 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Ошибка обработки URL {cover}: {e}")
 | 
			
		||||
 | 
			
		||||
        # SteamGridDB (SGDB)
 | 
			
		||||
        if cover and cover.startswith("https://cdn2.steamgriddb.com"):
 | 
			
		||||
            try:
 | 
			
		||||
                parts = cover.split("/")
 | 
			
		||||
                filename = parts[-1] if parts else "sgdb_cover.png"
 | 
			
		||||
                # SGDB ссылки содержат уникальный хеш в названии — используем как имя
 | 
			
		||||
                local_path = os.path.join(image_folder, filename)
 | 
			
		||||
 | 
			
		||||
                if os.path.exists(local_path):
 | 
			
		||||
                    pixmap = QPixmap(local_path)
 | 
			
		||||
                    finish_with(pixmap)
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
                def on_downloaded(result: str | None):
 | 
			
		||||
                    pixmap = QPixmap()
 | 
			
		||||
                    if result and os.path.exists(result):
 | 
			
		||||
                        pixmap.load(result)
 | 
			
		||||
                    if pixmap.isNull():
 | 
			
		||||
                        placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
 | 
			
		||||
                        if placeholder_path and QFile.exists(placeholder_path):
 | 
			
		||||
                            pixmap.load(placeholder_path)
 | 
			
		||||
                        else:
 | 
			
		||||
                            pixmap = QPixmap(width, height)
 | 
			
		||||
                            pixmap.fill(QColor("#333333"))
 | 
			
		||||
                            painter = QPainter(pixmap)
 | 
			
		||||
                            painter.setPen(QPen(QColor("white")))
 | 
			
		||||
                            painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
 | 
			
		||||
                            painter.end()
 | 
			
		||||
                    finish_with(pixmap)
 | 
			
		||||
 | 
			
		||||
                logger.info("Downloading SGDB cover for %s -> %s", app_name or "unknown", filename)
 | 
			
		||||
                downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Ошибка обработки SGDB URL {cover}: {e}")
 | 
			
		||||
 | 
			
		||||
        if cover and cover.startswith(("http://", "https://")):
 | 
			
		||||
            try:
 | 
			
		||||
                local_path = os.path.join(image_folder, f"{app_name}.jpg")
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-10-09 16:37+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
 | 
			
		||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 | 
			
		||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
			
		||||
"Language: de_DE\n"
 | 
			
		||||
@@ -252,13 +252,37 @@ msgstr ""
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgid "Open"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Toggle"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Force Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Next Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -326,12 +350,6 @@ msgstr ""
 | 
			
		||||
msgid "Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Force Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Winetricks not found. Please try again."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -395,9 +413,6 @@ msgstr ""
 | 
			
		||||
msgid "Auto Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Emulators"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Wine Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -416,6 +431,25 @@ msgstr ""
 | 
			
		||||
msgid "Search"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation already in progress."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start installation."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Processed {} installation..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation completed successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation error."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Loading Steam games..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -432,12 +466,6 @@ msgstr ""
 | 
			
		||||
msgid "Added '{name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Here you can configure automatic game installation..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "List of available emulators and their configuration..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Compatibility tool:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -450,12 +478,6 @@ msgstr ""
 | 
			
		||||
msgid "Registry Editor"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Control Panel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Task Manager"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Command Prompt"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -477,6 +499,29 @@ msgstr ""
 | 
			
		||||
msgid "Clear Prefix"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Launching tool..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Clear"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to clear prefix '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Prefix '{}' cleared successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid ""
 | 
			
		||||
"Prefix '{}' cleared with errors:\n"
 | 
			
		||||
"{}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start backup process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -552,6 +597,9 @@ msgstr ""
 | 
			
		||||
msgid "Games Display Filter:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Gamepad Type:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Proxy URL"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -576,6 +624,12 @@ msgstr ""
 | 
			
		||||
msgid "Application Fullscreen Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Minimize to tray on close"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Application Close Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Auto Fullscreen on Gamepad connected"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-10-09 16:37+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
 | 
			
		||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 | 
			
		||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
			
		||||
"Language: es_ES\n"
 | 
			
		||||
@@ -252,13 +252,37 @@ msgstr ""
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgid "Open"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Toggle"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Force Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Next Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -326,12 +350,6 @@ msgstr ""
 | 
			
		||||
msgid "Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Force Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Winetricks not found. Please try again."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -395,9 +413,6 @@ msgstr ""
 | 
			
		||||
msgid "Auto Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Emulators"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Wine Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -416,6 +431,25 @@ msgstr ""
 | 
			
		||||
msgid "Search"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation already in progress."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start installation."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Processed {} installation..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation completed successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation error."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Loading Steam games..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -432,12 +466,6 @@ msgstr ""
 | 
			
		||||
msgid "Added '{name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Here you can configure automatic game installation..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "List of available emulators and their configuration..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Compatibility tool:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -450,12 +478,6 @@ msgstr ""
 | 
			
		||||
msgid "Registry Editor"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Control Panel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Task Manager"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Command Prompt"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -477,6 +499,29 @@ msgstr ""
 | 
			
		||||
msgid "Clear Prefix"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Launching tool..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Clear"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to clear prefix '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Prefix '{}' cleared successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid ""
 | 
			
		||||
"Prefix '{}' cleared with errors:\n"
 | 
			
		||||
"{}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start backup process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -552,6 +597,9 @@ msgstr ""
 | 
			
		||||
msgid "Games Display Filter:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Gamepad Type:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Proxy URL"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -576,6 +624,12 @@ msgstr ""
 | 
			
		||||
msgid "Application Fullscreen Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Minimize to tray on close"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Application Close Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Auto Fullscreen on Gamepad connected"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-10-09 16:37+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
 | 
			
		||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 | 
			
		||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
			
		||||
"Language-Team: LANGUAGE <LL@li.org>\n"
 | 
			
		||||
@@ -250,13 +250,37 @@ msgstr ""
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgid "Open"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Toggle"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Force Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Next Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -324,12 +348,6 @@ msgstr ""
 | 
			
		||||
msgid "Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Force Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Winetricks not found. Please try again."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -393,9 +411,6 @@ msgstr ""
 | 
			
		||||
msgid "Auto Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Emulators"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Wine Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -414,6 +429,25 @@ msgstr ""
 | 
			
		||||
msgid "Search"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation already in progress."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start installation."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Processed {} installation..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation completed successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation error."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Loading Steam games..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -430,12 +464,6 @@ msgstr ""
 | 
			
		||||
msgid "Added '{name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Here you can configure automatic game installation..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "List of available emulators and their configuration..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Compatibility tool:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -448,12 +476,6 @@ msgstr ""
 | 
			
		||||
msgid "Registry Editor"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Control Panel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Task Manager"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Command Prompt"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -475,6 +497,29 @@ msgstr ""
 | 
			
		||||
msgid "Clear Prefix"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Launching tool..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Clear"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to clear prefix '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Prefix '{}' cleared successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid ""
 | 
			
		||||
"Prefix '{}' cleared with errors:\n"
 | 
			
		||||
"{}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start backup process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -550,6 +595,9 @@ msgstr ""
 | 
			
		||||
msgid "Games Display Filter:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Gamepad Type:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Proxy URL"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -574,6 +622,12 @@ msgstr ""
 | 
			
		||||
msgid "Application Fullscreen Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Minimize to tray on close"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Application Close Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Auto Fullscreen on Gamepad connected"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,8 +9,8 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-10-09 16:37+0500\n"
 | 
			
		||||
"PO-Revision-Date: 2025-10-09 16:37+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
 | 
			
		||||
"PO-Revision-Date: 2025-10-16 14:54+0500\n"
 | 
			
		||||
"Last-Translator: \n"
 | 
			
		||||
"Language: ru_RU\n"
 | 
			
		||||
"Language-Team: ru_RU <LL@li.org>\n"
 | 
			
		||||
@@ -259,13 +259,37 @@ msgstr "Удалить"
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr "Выбрать всё"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr "Идёт запуск {0}"
 | 
			
		||||
msgid "Open"
 | 
			
		||||
msgstr "Открыть"
 | 
			
		||||
 | 
			
		||||
msgid "Select Dir"
 | 
			
		||||
msgstr "Выбрать папку"
 | 
			
		||||
 | 
			
		||||
msgid "Prev Dir"
 | 
			
		||||
msgstr "Предыдущий каталог"
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr "Отмена"
 | 
			
		||||
 | 
			
		||||
msgid "Toggle"
 | 
			
		||||
msgstr "Переключить"
 | 
			
		||||
 | 
			
		||||
msgid "Install"
 | 
			
		||||
msgstr "Установить"
 | 
			
		||||
 | 
			
		||||
msgid "Force Install"
 | 
			
		||||
msgstr "Принудительно установить"
 | 
			
		||||
 | 
			
		||||
msgid "Prev Tab"
 | 
			
		||||
msgstr "Предыдущая вкладка"
 | 
			
		||||
 | 
			
		||||
msgid "Next Tab"
 | 
			
		||||
msgstr "Следующая вкладка"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr "Идёт запуск {0}"
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr "Проводник"
 | 
			
		||||
 | 
			
		||||
@@ -333,12 +357,6 @@ msgstr "Шрифты"
 | 
			
		||||
msgid "Settings"
 | 
			
		||||
msgstr "Настройки"
 | 
			
		||||
 | 
			
		||||
msgid "Force Install"
 | 
			
		||||
msgstr "Принудительно установить"
 | 
			
		||||
 | 
			
		||||
msgid "Install"
 | 
			
		||||
msgstr "Установить"
 | 
			
		||||
 | 
			
		||||
msgid "Winetricks not found. Please try again."
 | 
			
		||||
msgstr "Winetricks не найден. Повторите попытку."
 | 
			
		||||
 | 
			
		||||
@@ -402,9 +420,6 @@ msgstr "Библиотека"
 | 
			
		||||
msgid "Auto Install"
 | 
			
		||||
msgstr "Автоустановка"
 | 
			
		||||
 | 
			
		||||
msgid "Emulators"
 | 
			
		||||
msgstr "Эмуляторы"
 | 
			
		||||
 | 
			
		||||
msgid "Wine Settings"
 | 
			
		||||
msgstr "Настройки wine"
 | 
			
		||||
 | 
			
		||||
@@ -423,6 +438,25 @@ msgstr "Полный экран"
 | 
			
		||||
msgid "Search"
 | 
			
		||||
msgstr "Поиск"
 | 
			
		||||
 | 
			
		||||
msgid "Installation already in progress."
 | 
			
		||||
msgstr "Установка уже выполняется."
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start installation."
 | 
			
		||||
msgstr "Не удалось запустить установку."
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Processed {} installation..."
 | 
			
		||||
msgstr "В процессе установки {}..."
 | 
			
		||||
 | 
			
		||||
msgid "Installation completed successfully."
 | 
			
		||||
msgstr "Установка завершена успешно."
 | 
			
		||||
 | 
			
		||||
msgid "Installation failed."
 | 
			
		||||
msgstr "Установка не удалась."
 | 
			
		||||
 | 
			
		||||
msgid "Installation error."
 | 
			
		||||
msgstr "Ошибка установки."
 | 
			
		||||
 | 
			
		||||
msgid "Loading Steam games..."
 | 
			
		||||
msgstr "Загрузка игр из Steam..."
 | 
			
		||||
 | 
			
		||||
@@ -439,12 +473,6 @@ msgstr "Найти игры..."
 | 
			
		||||
msgid "Added '{name}'"
 | 
			
		||||
msgstr "'{name}' добавлен(а)"
 | 
			
		||||
 | 
			
		||||
msgid "Here you can configure automatic game installation..."
 | 
			
		||||
msgstr "Здесь можно настроить автоматическую установку игр..."
 | 
			
		||||
 | 
			
		||||
msgid "List of available emulators and their configuration..."
 | 
			
		||||
msgstr "Список доступных эмуляторов и их настройка..."
 | 
			
		||||
 | 
			
		||||
msgid "Compatibility tool:"
 | 
			
		||||
msgstr "Инструмент совместимости:"
 | 
			
		||||
 | 
			
		||||
@@ -457,12 +485,6 @@ msgstr "Конфигурация Wine"
 | 
			
		||||
msgid "Registry Editor"
 | 
			
		||||
msgstr "Редактор реестра"
 | 
			
		||||
 | 
			
		||||
msgid "Control Panel"
 | 
			
		||||
msgstr "Панель управления"
 | 
			
		||||
 | 
			
		||||
msgid "Task Manager"
 | 
			
		||||
msgstr "Диспетчер задач"
 | 
			
		||||
 | 
			
		||||
msgid "Command Prompt"
 | 
			
		||||
msgstr "Командная строка"
 | 
			
		||||
 | 
			
		||||
@@ -484,6 +506,31 @@ msgstr "Удалить Префикс"
 | 
			
		||||
msgid "Clear Prefix"
 | 
			
		||||
msgstr "Очистить Префикс"
 | 
			
		||||
 | 
			
		||||
msgid "Launching tool..."
 | 
			
		||||
msgstr "Запуск инструмента..."
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start process."
 | 
			
		||||
msgstr "Не удалось запустить процесс."
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Clear"
 | 
			
		||||
msgstr "Подтвердите очистку"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to clear prefix '{}'?"
 | 
			
		||||
msgstr "Вы уверены, что хотите очистить префикс «{}»?"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Prefix '{}' cleared successfully."
 | 
			
		||||
msgstr "Префикс '{}' успешно удален."
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid ""
 | 
			
		||||
"Prefix '{}' cleared with errors:\n"
 | 
			
		||||
"{}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Префикс '{}' очищен с ошибками:\n"
 | 
			
		||||
"{}"
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start backup process."
 | 
			
		||||
msgstr "Не удалось запустить процесс резервного копирования."
 | 
			
		||||
 | 
			
		||||
@@ -559,6 +606,9 @@ msgstr "все"
 | 
			
		||||
msgid "Games Display Filter:"
 | 
			
		||||
msgstr "Фильтр игр:"
 | 
			
		||||
 | 
			
		||||
msgid "Gamepad Type:"
 | 
			
		||||
msgstr "Тип геймпада:"
 | 
			
		||||
 | 
			
		||||
msgid "Proxy URL"
 | 
			
		||||
msgstr "Адрес прокси"
 | 
			
		||||
 | 
			
		||||
@@ -583,6 +633,12 @@ msgstr "Запуск приложения в полноэкранном режи
 | 
			
		||||
msgid "Application Fullscreen Mode:"
 | 
			
		||||
msgstr "Режим полноэкранного отображения приложения:"
 | 
			
		||||
 | 
			
		||||
msgid "Minimize to tray on close"
 | 
			
		||||
msgstr "Сворачивать в трей при закрытии"
 | 
			
		||||
 | 
			
		||||
msgid "Application Close Mode:"
 | 
			
		||||
msgstr "Режим закрытия приложения:"
 | 
			
		||||
 | 
			
		||||
msgid "Auto Fullscreen on Gamepad connected"
 | 
			
		||||
msgstr "Режим полноэкранного отображения приложения при подключении геймпада"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -5,12 +5,13 @@ import signal
 | 
			
		||||
import subprocess
 | 
			
		||||
import sys
 | 
			
		||||
import psutil
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog
 | 
			
		||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog, ExeSettingsDialog
 | 
			
		||||
from portprotonqt.game_card import GameCard
 | 
			
		||||
from portprotonqt.animations import DetailPageAnimations
 | 
			
		||||
from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel
 | 
			
		||||
from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel, FlowLayout
 | 
			
		||||
from portprotonqt.portproton_api import PortProtonAPI
 | 
			
		||||
from portprotonqt.input_manager import InputManager
 | 
			
		||||
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
 | 
			
		||||
@@ -28,7 +29,8 @@ from portprotonqt.config_utils import (
 | 
			
		||||
    read_display_filter, read_favorites, save_favorites, save_time_config, save_sort_method,
 | 
			
		||||
    save_display_filter, save_proxy_config, read_proxy_config, read_fullscreen_config,
 | 
			
		||||
    save_fullscreen_config, read_window_geometry, save_window_geometry, reset_config,
 | 
			
		||||
    clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config
 | 
			
		||||
    clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config, read_gamepad_type, save_gamepad_type, read_minimize_to_tray, save_minimize_to_tray,
 | 
			
		||||
    read_auto_card_size, save_auto_card_size, get_portproton_start_command
 | 
			
		||||
)
 | 
			
		||||
from portprotonqt.localization import _, get_egs_language, read_metadata_translations
 | 
			
		||||
from portprotonqt.howlongtobeat_api import HowLongToBeat
 | 
			
		||||
@@ -38,7 +40,7 @@ from portprotonqt.game_library_manager import GameLibraryManager
 | 
			
		||||
from portprotonqt.virtual_keyboard import VirtualKeyboard
 | 
			
		||||
 | 
			
		||||
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox,
 | 
			
		||||
                               QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout)
 | 
			
		||||
                               QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout, QScrollArea, QScroller, QSlider)
 | 
			
		||||
from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot, QProcess
 | 
			
		||||
from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices
 | 
			
		||||
from typing import cast
 | 
			
		||||
@@ -53,7 +55,7 @@ class MainWindow(QMainWindow):
 | 
			
		||||
    update_progress = Signal(int)
 | 
			
		||||
    update_status_message = Signal(str, int)
 | 
			
		||||
 | 
			
		||||
    def __init__(self, app_name: str):
 | 
			
		||||
    def __init__(self, app_name: str, version: str):
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        self.is_exiting = False
 | 
			
		||||
@@ -62,8 +64,9 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        self.theme = self.theme_manager.apply_theme(selected_theme)
 | 
			
		||||
        self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
 | 
			
		||||
        self.card_width = read_card_size()
 | 
			
		||||
        self.auto_card_width = read_auto_card_size()
 | 
			
		||||
        self._last_card_width = self.card_width
 | 
			
		||||
        self.setWindowTitle(app_name)
 | 
			
		||||
        self.setWindowTitle(f"{app_name} {version}")
 | 
			
		||||
        self.setMinimumSize(800, 600)
 | 
			
		||||
 | 
			
		||||
        self.games = []
 | 
			
		||||
@@ -71,6 +74,7 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        self.target_exe = None
 | 
			
		||||
        self.current_running_button = None
 | 
			
		||||
        self.portproton_location = get_portproton_location()
 | 
			
		||||
        self.start_sh = get_portproton_start_command()
 | 
			
		||||
 | 
			
		||||
        self.game_library_manager = GameLibraryManager(self, self.theme, None)
 | 
			
		||||
 | 
			
		||||
@@ -129,6 +133,11 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        self.update_progress.connect(self.progress_bar.setValue)
 | 
			
		||||
        self.update_status_message.connect(self.statusBar().showMessage)
 | 
			
		||||
 | 
			
		||||
        self.installing = False
 | 
			
		||||
        self.current_install_script = None
 | 
			
		||||
        self.install_process = None
 | 
			
		||||
        self.install_monitor_timer = None
 | 
			
		||||
 | 
			
		||||
        # Центральный виджет и основной layout
 | 
			
		||||
        centralWidget = QWidget()
 | 
			
		||||
        self.setCentralWidget(centralWidget)
 | 
			
		||||
@@ -166,7 +175,6 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        tabs = [
 | 
			
		||||
            _("Library"),
 | 
			
		||||
            _("Auto Install"),
 | 
			
		||||
            _("Emulators"),
 | 
			
		||||
            _("Wine Settings"),
 | 
			
		||||
            _("PortProton Settings"),
 | 
			
		||||
            _("Themes")
 | 
			
		||||
@@ -198,7 +206,6 @@ class MainWindow(QMainWindow):
 | 
			
		||||
 | 
			
		||||
        self.createInstalledTab()
 | 
			
		||||
        self.createAutoInstallTab()
 | 
			
		||||
        self.createEmulatorsTab()
 | 
			
		||||
        self.createWineTab()
 | 
			
		||||
        self.createPortProtonTab()
 | 
			
		||||
        self.createThemeTab()
 | 
			
		||||
@@ -256,6 +263,10 @@ class MainWindow(QMainWindow):
 | 
			
		||||
                GamepadType.XBOX: "xbox_y",
 | 
			
		||||
                GamepadType.PLAYSTATION: "ps_square",
 | 
			
		||||
            },
 | 
			
		||||
            'prev_dir': {
 | 
			
		||||
                GamepadType.XBOX: "xbox_y",
 | 
			
		||||
                GamepadType.PLAYSTATION: "ps_square",
 | 
			
		||||
            },
 | 
			
		||||
        }
 | 
			
		||||
        return mappings.get(action, {}).get(gtype, "placeholder")
 | 
			
		||||
 | 
			
		||||
@@ -439,6 +450,116 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        # Update navigation buttons
 | 
			
		||||
        self.updateNavButtons()
 | 
			
		||||
 | 
			
		||||
    def launch_autoinstall(self, script_name: str):
 | 
			
		||||
        """Launch auto-install script."""
 | 
			
		||||
        if self.installing:
 | 
			
		||||
            QMessageBox.warning(self, _("Warning"), _("Installation already in progress."))
 | 
			
		||||
            return
 | 
			
		||||
        self.installing = True
 | 
			
		||||
        self.current_install_script = script_name
 | 
			
		||||
        self.seen_progress = False
 | 
			
		||||
        self.current_percent = 0.0
 | 
			
		||||
        start_sh = self.start_sh
 | 
			
		||||
        if not start_sh:
 | 
			
		||||
            self.installing = False
 | 
			
		||||
            return
 | 
			
		||||
        cmd = start_sh + ["cli", "--autoinstall", script_name]
 | 
			
		||||
        self.install_process = QProcess(self)
 | 
			
		||||
        self.install_process.finished.connect(self.on_install_finished)
 | 
			
		||||
        self.install_process.errorOccurred.connect(self.on_install_error)
 | 
			
		||||
        self.install_process.start(cmd[0], cmd[1:])
 | 
			
		||||
        if not self.install_process.waitForStarted(5000):
 | 
			
		||||
            self.installing = False
 | 
			
		||||
            QMessageBox.warning(self, _("Error"), _("Failed to start installation."))
 | 
			
		||||
            return
 | 
			
		||||
        self.progress_bar.setVisible(True)
 | 
			
		||||
        self.progress_bar.setRange(0, 0)  # Indeterminate
 | 
			
		||||
        self.update_status_message.emit(_("Processed {} installation...").format(script_name), 0)
 | 
			
		||||
        self.install_monitor_timer = QTimer(self)
 | 
			
		||||
        self.install_monitor_timer.timeout.connect(self.monitor_install_progress)
 | 
			
		||||
        self.install_monitor_timer.start(2000)  # Start monitoring after 2s
 | 
			
		||||
 | 
			
		||||
    def monitor_install_progress(self):
 | 
			
		||||
        """Monitor /tmp/PortProton_$USER/process.log for progress."""
 | 
			
		||||
        user = os.getenv('USER', 'unknown')
 | 
			
		||||
        log_file = f"/tmp/PortProton_{user}/process.log"
 | 
			
		||||
        if not os.path.exists(log_file):
 | 
			
		||||
            return
 | 
			
		||||
        try:
 | 
			
		||||
            with open(log_file, encoding='utf-8') as f:
 | 
			
		||||
                content = f.read()
 | 
			
		||||
            # Extract all percentage matches, including .0% as 0.0
 | 
			
		||||
            matches = re.findall(r'([0-9]*\.?[0-9]+)%', content)
 | 
			
		||||
            if matches:
 | 
			
		||||
                try:
 | 
			
		||||
                    percent = float(matches[-1])
 | 
			
		||||
                    if percent > 0:
 | 
			
		||||
                        self.seen_progress = True
 | 
			
		||||
                        self.current_percent = percent
 | 
			
		||||
                    elif self.seen_progress and percent == 0:
 | 
			
		||||
                        self.current_percent = 100.0
 | 
			
		||||
                        if self.install_monitor_timer is not None:
 | 
			
		||||
                            self.install_monitor_timer.stop()
 | 
			
		||||
                    # Update progress bar to determinate if not already
 | 
			
		||||
                    if self.progress_bar.maximum() == 0:
 | 
			
		||||
                        self.progress_bar.setRange(0, 100)
 | 
			
		||||
                        self.progress_bar.setFormat("%p")  # Show percentage
 | 
			
		||||
                    self.progress_bar.setValue(int(self.current_percent))
 | 
			
		||||
                    if self.current_percent >= 100:
 | 
			
		||||
                        if self.install_monitor_timer is not None:
 | 
			
		||||
                            self.install_monitor_timer.stop()
 | 
			
		||||
                except ValueError:
 | 
			
		||||
                    pass  # Ignore invalid floats
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error monitoring log: {e}")
 | 
			
		||||
 | 
			
		||||
    @Slot(int, int)
 | 
			
		||||
    def on_install_finished(self, exit_code: int, exit_status: int):
 | 
			
		||||
        """Handle installation finish."""
 | 
			
		||||
        self.installing = False
 | 
			
		||||
        if self.install_monitor_timer is not None:
 | 
			
		||||
            self.install_monitor_timer.stop()
 | 
			
		||||
            self.install_monitor_timer.deleteLater()
 | 
			
		||||
            self.install_monitor_timer = None
 | 
			
		||||
        self.progress_bar.setRange(0, 100)
 | 
			
		||||
        self.progress_bar.setValue(100)
 | 
			
		||||
 | 
			
		||||
        if exit_code == 0:
 | 
			
		||||
            self.update_status_message.emit(_("Installation completed successfully."), 5000)
 | 
			
		||||
 | 
			
		||||
            desktop_dir = self.portproton_location or ""
 | 
			
		||||
            new_desktops = [e.path for e in os.scandir(desktop_dir) if e.name.endswith(".desktop")]
 | 
			
		||||
            if new_desktops:
 | 
			
		||||
                latest = max(new_desktops, key=os.path.getmtime)
 | 
			
		||||
                self._process_desktop_file_async(
 | 
			
		||||
                    latest,
 | 
			
		||||
                    lambda result: (
 | 
			
		||||
                        self.game_library_manager.add_game_incremental(result)
 | 
			
		||||
                        if result else None
 | 
			
		||||
                    )
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
        else:
 | 
			
		||||
            self.update_status_message.emit(_("Installation failed."), 5000)
 | 
			
		||||
            QMessageBox.warning(self, _("Error"), f"Installation failed (code: {exit_code}).")
 | 
			
		||||
 | 
			
		||||
        self.progress_bar.setVisible(False)
 | 
			
		||||
        self.current_install_script = None
 | 
			
		||||
        if self.install_process:
 | 
			
		||||
            self.install_process.deleteLater()
 | 
			
		||||
            self.install_process = None
 | 
			
		||||
 | 
			
		||||
    def on_install_error(self, error: QProcess.ProcessError):
 | 
			
		||||
        """Handle installation error."""
 | 
			
		||||
        self.installing = False
 | 
			
		||||
        if self.install_monitor_timer is not None:
 | 
			
		||||
            self.install_monitor_timer.stop()
 | 
			
		||||
            self.install_monitor_timer.deleteLater()
 | 
			
		||||
            self.install_monitor_timer = None
 | 
			
		||||
        self.update_status_message.emit(_("Installation error."), 5000)
 | 
			
		||||
        QMessageBox.warning(self, _("Error"), f"Process error: {error}")
 | 
			
		||||
        self.progress_bar.setVisible(False)
 | 
			
		||||
 | 
			
		||||
    @Slot(list)
 | 
			
		||||
    def on_games_loaded(self, games: list[tuple]):
 | 
			
		||||
        self.game_library_manager.set_games(games)
 | 
			
		||||
@@ -720,6 +841,25 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        for i, btn in self.tabButtons.items():
 | 
			
		||||
            btn.setChecked(i == index)
 | 
			
		||||
        self.stackedWidget.setCurrentIndex(index)
 | 
			
		||||
        if hasattr(self, "game_library_manager"):
 | 
			
		||||
            mgr = self.game_library_manager
 | 
			
		||||
            if mgr.gamesListWidget and mgr.gamesListLayout:
 | 
			
		||||
                games_layout = mgr.gamesListLayout
 | 
			
		||||
                games_widget = mgr.gamesListWidget
 | 
			
		||||
                QTimer.singleShot(0, lambda: (
 | 
			
		||||
                    games_layout.invalidate(),
 | 
			
		||||
                    games_widget.adjustSize(),
 | 
			
		||||
                    games_widget.updateGeometry()
 | 
			
		||||
                ))
 | 
			
		||||
        if hasattr(self, "autoInstallContainer") and hasattr(self, "autoInstallContainerLayout"):
 | 
			
		||||
            auto_layout = self.autoInstallContainerLayout
 | 
			
		||||
            auto_widget = self.autoInstallContainer
 | 
			
		||||
            QTimer.singleShot(0, lambda: (
 | 
			
		||||
                auto_layout.invalidate(),
 | 
			
		||||
                auto_widget.adjustSize(),
 | 
			
		||||
                auto_widget.updateGeometry()
 | 
			
		||||
            ))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def openSystemOverlay(self):
 | 
			
		||||
        """Opens the system overlay dialog."""
 | 
			
		||||
@@ -960,52 +1100,205 @@ class MainWindow(QMainWindow):
 | 
			
		||||
                get_steam_game_info_async(final_name, exec_line, on_steam_info)
 | 
			
		||||
 | 
			
		||||
    def createAutoInstallTab(self):
 | 
			
		||||
        """Вкладка 'Auto Install'."""
 | 
			
		||||
        self.autoInstallWidget = QWidget()
 | 
			
		||||
        self.autoInstallWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
 | 
			
		||||
        self.autoInstallWidget.setObjectName("otherPage")
 | 
			
		||||
        layout = QVBoxLayout(self.autoInstallWidget)
 | 
			
		||||
        layout.setContentsMargins(10, 18, 10, 10)
 | 
			
		||||
        autoInstallPage = QWidget()
 | 
			
		||||
        autoInstallPage.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
 | 
			
		||||
        autoInstallLayout = QVBoxLayout(autoInstallPage)
 | 
			
		||||
        autoInstallLayout.setSpacing(15)
 | 
			
		||||
 | 
			
		||||
        self.autoInstallTitle = QLabel(_("Auto Install"))
 | 
			
		||||
        self.autoInstallTitle.setStyleSheet(self.theme.TAB_TITLE_STYLE)
 | 
			
		||||
        self.autoInstallTitle.setObjectName("tabTitle")
 | 
			
		||||
        layout.addWidget(self.autoInstallTitle)
 | 
			
		||||
        # Верхняя панель с заголовком и поиском
 | 
			
		||||
        headerWidget = QWidget()
 | 
			
		||||
        headerLayout = QHBoxLayout(headerWidget)
 | 
			
		||||
        headerLayout.setContentsMargins(0, 10, 0, 10)
 | 
			
		||||
        headerLayout.setSpacing(10)
 | 
			
		||||
 | 
			
		||||
        self.autoInstallContent = QLabel(_("Here you can configure automatic game installation..."))
 | 
			
		||||
        self.autoInstallContent.setStyleSheet(self.theme.CONTENT_STYLE)
 | 
			
		||||
        self.autoInstallContent.setObjectName("tabContent")
 | 
			
		||||
        layout.addWidget(self.autoInstallContent)
 | 
			
		||||
        layout.addStretch(1)
 | 
			
		||||
        # Заголовок
 | 
			
		||||
        titleLabel = QLabel(_("Auto Install"))
 | 
			
		||||
        titleLabel.setStyleSheet(self.theme.TAB_TITLE_STYLE)
 | 
			
		||||
        titleLabel.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
 | 
			
		||||
        headerLayout.addWidget(titleLabel)
 | 
			
		||||
 | 
			
		||||
        self.stackedWidget.addWidget(self.autoInstallWidget)
 | 
			
		||||
        headerLayout.addStretch()
 | 
			
		||||
 | 
			
		||||
    def createEmulatorsTab(self):
 | 
			
		||||
        """Вкладка 'Emulators'."""
 | 
			
		||||
        self.emulatorsWidget = QWidget()
 | 
			
		||||
        self.emulatorsWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
 | 
			
		||||
        self.emulatorsWidget.setObjectName("otherPage")
 | 
			
		||||
        layout = QVBoxLayout(self.emulatorsWidget)
 | 
			
		||||
        layout.setContentsMargins(10, 18, 10, 10)
 | 
			
		||||
        # Поисковая строка
 | 
			
		||||
        self.autoInstallSearchLineEdit = CustomLineEdit(self, theme=self.theme)
 | 
			
		||||
        icon: QIcon = cast(QIcon, self.theme_manager.get_icon("search"))
 | 
			
		||||
        action_pos = QLineEdit.ActionPosition.LeadingPosition
 | 
			
		||||
        self.search_action = self.autoInstallSearchLineEdit.addAction(icon, action_pos)
 | 
			
		||||
        self.autoInstallSearchLineEdit.setMaximumWidth(200)
 | 
			
		||||
        self.autoInstallSearchLineEdit.setPlaceholderText(_("Find Games ..."))
 | 
			
		||||
        self.autoInstallSearchLineEdit.setClearButtonEnabled(True)
 | 
			
		||||
        self.autoInstallSearchLineEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE)
 | 
			
		||||
        self.autoInstallSearchLineEdit.textChanged.connect(self.filterAutoInstallGames)
 | 
			
		||||
        headerLayout.addWidget(self.autoInstallSearchLineEdit)
 | 
			
		||||
 | 
			
		||||
        self.emulatorsTitle = QLabel(_("Emulators"))
 | 
			
		||||
        self.emulatorsTitle.setStyleSheet(self.theme.TAB_TITLE_STYLE)
 | 
			
		||||
        self.emulatorsTitle.setObjectName("tabTitle")
 | 
			
		||||
        layout.addWidget(self.emulatorsTitle)
 | 
			
		||||
        autoInstallLayout.addWidget(headerWidget)
 | 
			
		||||
 | 
			
		||||
        self.emulatorsContent = QLabel(_("List of available emulators and their configuration..."))
 | 
			
		||||
        self.emulatorsContent.setStyleSheet(self.theme.CONTENT_STYLE)
 | 
			
		||||
        self.emulatorsContent.setObjectName("tabContent")
 | 
			
		||||
        layout.addWidget(self.emulatorsContent)
 | 
			
		||||
        layout.addStretch(1)
 | 
			
		||||
        # Прогресс-бар
 | 
			
		||||
        self.autoInstallProgress = QProgressBar()
 | 
			
		||||
        self.autoInstallProgress.setStyleSheet(self.theme.PROGRESS_BAR_STYLE)
 | 
			
		||||
        self.autoInstallProgress.setVisible(False)
 | 
			
		||||
        autoInstallLayout.addWidget(self.autoInstallProgress)
 | 
			
		||||
 | 
			
		||||
        self.stackedWidget.addWidget(self.emulatorsWidget)
 | 
			
		||||
        # Скролл
 | 
			
		||||
        self.autoInstallScrollArea = QScrollArea()
 | 
			
		||||
        self.autoInstallScrollArea.setWidgetResizable(True)
 | 
			
		||||
        self.autoInstallScrollArea.setStyleSheet(self.theme.SCROLL_AREA_STYLE)
 | 
			
		||||
        QScroller.grabGesture(self.autoInstallScrollArea.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture)
 | 
			
		||||
 | 
			
		||||
        self.autoInstallContainer = QWidget()
 | 
			
		||||
        self.autoInstallContainerLayout = FlowLayout(self.autoInstallContainer)
 | 
			
		||||
        self.autoInstallContainer.setLayout(self.autoInstallContainerLayout)
 | 
			
		||||
        self.autoInstallScrollArea.setWidget(self.autoInstallContainer)
 | 
			
		||||
 | 
			
		||||
        autoInstallLayout.addWidget(self.autoInstallScrollArea)
 | 
			
		||||
 | 
			
		||||
        # Slider for card size
 | 
			
		||||
        sliderLayout = QHBoxLayout()
 | 
			
		||||
        sliderLayout.setSpacing(0)
 | 
			
		||||
        sliderLayout.setContentsMargins(0, 0, 0, 0)
 | 
			
		||||
        sliderLayout.addStretch()
 | 
			
		||||
 | 
			
		||||
        self.auto_size_slider = QSlider(Qt.Orientation.Horizontal)
 | 
			
		||||
        self.auto_size_slider.setMinimum(200)
 | 
			
		||||
        self.auto_size_slider.setMaximum(250)
 | 
			
		||||
        self.auto_size_slider.setValue(self.auto_card_width)
 | 
			
		||||
        self.auto_size_slider.setTickInterval(10)
 | 
			
		||||
        self.auto_size_slider.setFixedWidth(150)
 | 
			
		||||
        self.auto_size_slider.setToolTip(f"{self.auto_card_width} px")
 | 
			
		||||
        self.auto_size_slider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE)
 | 
			
		||||
        self.auto_size_slider.sliderReleased.connect(self.on_auto_slider_released)
 | 
			
		||||
        sliderLayout.addWidget(self.auto_size_slider)
 | 
			
		||||
 | 
			
		||||
        autoInstallLayout.addLayout(sliderLayout)
 | 
			
		||||
 | 
			
		||||
        # Хранение карточек
 | 
			
		||||
        self.autoInstallGameCards = {}
 | 
			
		||||
        self.allAutoInstallCards = []
 | 
			
		||||
 | 
			
		||||
        # Обновление обложки
 | 
			
		||||
        def on_autoinstall_cover_updated(exe_name, local_path):
 | 
			
		||||
            if exe_name in self.autoInstallGameCards and local_path:
 | 
			
		||||
                card = self.autoInstallGameCards[exe_name]
 | 
			
		||||
                card.cover_path = local_path
 | 
			
		||||
                load_pixmap_async(local_path, self.auto_card_width, int(self.auto_card_width * 1.5), card.on_cover_loaded)
 | 
			
		||||
 | 
			
		||||
        # Загрузка игр
 | 
			
		||||
        def on_autoinstall_games_loaded(games: list[tuple]):
 | 
			
		||||
            self.autoInstallProgress.setVisible(False)
 | 
			
		||||
 | 
			
		||||
            # Очистка
 | 
			
		||||
            while self.autoInstallContainerLayout.count():
 | 
			
		||||
                child = self.autoInstallContainerLayout.takeAt(0)
 | 
			
		||||
                if child:
 | 
			
		||||
                    child.widget().deleteLater()
 | 
			
		||||
 | 
			
		||||
            self.autoInstallGameCards.clear()
 | 
			
		||||
            self.allAutoInstallCards.clear()
 | 
			
		||||
 | 
			
		||||
            if not games:
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            # Callback для запуска установки
 | 
			
		||||
            def select_callback(name, description, cover_path, appid, exec_line, controller_support, *_):
 | 
			
		||||
                if not exec_line or not exec_line.startswith("autoinstall:"):
 | 
			
		||||
                    logger.warning(f"Invalid exec_line for autoinstall: {exec_line}")
 | 
			
		||||
                    return
 | 
			
		||||
                script_name = exec_line[11:].lstrip(':').strip()
 | 
			
		||||
                self.launch_autoinstall(script_name)
 | 
			
		||||
 | 
			
		||||
            # Создаём карточки
 | 
			
		||||
            for game_tuple in games:
 | 
			
		||||
                name, description, cover_path, appid, controller_support, exec_line, *_ , game_source, exe_name = game_tuple
 | 
			
		||||
 | 
			
		||||
                card = GameCard(
 | 
			
		||||
                    name, description, cover_path, appid, controller_support,
 | 
			
		||||
                    exec_line, None, None, None,
 | 
			
		||||
                    None, None, None, game_source,
 | 
			
		||||
                    select_callback=select_callback,
 | 
			
		||||
                    theme=self.theme,
 | 
			
		||||
                    card_width=self.auto_card_width,
 | 
			
		||||
                    parent=self.autoInstallContainer,
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
                # Hide badges and favorite button
 | 
			
		||||
                if hasattr(card, 'steamLabel'):
 | 
			
		||||
                    card.steamLabel.setVisible(False)
 | 
			
		||||
                if hasattr(card, 'egsLabel'):
 | 
			
		||||
                    card.egsLabel.setVisible(False)
 | 
			
		||||
                if hasattr(card, 'portprotonLabel'):
 | 
			
		||||
                    card.portprotonLabel.setVisible(False)
 | 
			
		||||
                if hasattr(card, 'protondbLabel'):
 | 
			
		||||
                    card.protondbLabel.setVisible(False)
 | 
			
		||||
                if hasattr(card, 'anticheatLabel'):
 | 
			
		||||
                    card.anticheatLabel.setVisible(False)
 | 
			
		||||
                if hasattr(card, 'favoriteLabel'):
 | 
			
		||||
                    card.favoriteLabel.setVisible(False)
 | 
			
		||||
 | 
			
		||||
                self.autoInstallGameCards[exe_name] = card
 | 
			
		||||
                self.allAutoInstallCards.append(card)
 | 
			
		||||
                self.autoInstallContainerLayout.addWidget(card)
 | 
			
		||||
 | 
			
		||||
            # Загружаем недостающие обложки
 | 
			
		||||
            for game_tuple in games:
 | 
			
		||||
                name, _, cover_path, *_ , game_source, exe_name = game_tuple
 | 
			
		||||
                if not cover_path:
 | 
			
		||||
                    self.portproton_api.download_autoinstall_cover_async(
 | 
			
		||||
                        exe_name, timeout=5,
 | 
			
		||||
                        callback=lambda path, ex=exe_name: on_autoinstall_cover_updated(ex, path)
 | 
			
		||||
                    )
 | 
			
		||||
 | 
			
		||||
            self.autoInstallContainer.updateGeometry()
 | 
			
		||||
            self.autoInstallScrollArea.updateGeometry()
 | 
			
		||||
            self.filterAutoInstallGames()
 | 
			
		||||
 | 
			
		||||
        # Показываем прогресс
 | 
			
		||||
        self.autoInstallProgress.setVisible(True)
 | 
			
		||||
        self.autoInstallProgress.setRange(0, 0)
 | 
			
		||||
 | 
			
		||||
        # Store the thread to prevent premature destruction
 | 
			
		||||
        self.autoInstallLoadThread = self.portproton_api.start_autoinstall_games_load(on_autoinstall_games_loaded)
 | 
			
		||||
 | 
			
		||||
        # Optional: Clean up thread when finished (prevents leak)
 | 
			
		||||
        if self.autoInstallLoadThread:
 | 
			
		||||
            def on_thread_finished():
 | 
			
		||||
                self.autoInstallLoadThread = None  # Release reference
 | 
			
		||||
            self.autoInstallLoadThread.finished.connect(on_thread_finished)
 | 
			
		||||
 | 
			
		||||
        self.stackedWidget.addWidget(autoInstallPage)
 | 
			
		||||
 | 
			
		||||
    def on_auto_slider_released(self):
 | 
			
		||||
        """Handles auto-install slider release to update card size."""
 | 
			
		||||
        if hasattr(self, 'auto_size_slider') and self.auto_size_slider:
 | 
			
		||||
            self.auto_card_width = self.auto_size_slider.value()
 | 
			
		||||
            self.auto_size_slider.setToolTip(f"{self.auto_card_width} px")
 | 
			
		||||
            save_auto_card_size(self.auto_card_width)
 | 
			
		||||
            for card in self.allAutoInstallCards:
 | 
			
		||||
                card.update_card_size(self.auto_card_width)
 | 
			
		||||
            self.autoInstallContainerLayout.invalidate()
 | 
			
		||||
            self.autoInstallContainer.updateGeometry()
 | 
			
		||||
            self.autoInstallScrollArea.updateGeometry()
 | 
			
		||||
 | 
			
		||||
    def filterAutoInstallGames(self):
 | 
			
		||||
        """Filter auto install game cards based on search text."""
 | 
			
		||||
        search_text = self.autoInstallSearchLineEdit.text().lower().strip()
 | 
			
		||||
        visible_count = 0
 | 
			
		||||
 | 
			
		||||
        for card in self.allAutoInstallCards:
 | 
			
		||||
            if search_text in card.name.lower():
 | 
			
		||||
                card.setVisible(True)
 | 
			
		||||
                visible_count += 1
 | 
			
		||||
            else:
 | 
			
		||||
                card.setVisible(False)
 | 
			
		||||
 | 
			
		||||
        # Re-layout the container
 | 
			
		||||
        self.autoInstallContainerLayout.invalidate()
 | 
			
		||||
        self.autoInstallContainer.updateGeometry()
 | 
			
		||||
        self.autoInstallScrollArea.updateGeometry()
 | 
			
		||||
 | 
			
		||||
    def createWineTab(self):
 | 
			
		||||
        """Вкладка 'Wine Settings'."""
 | 
			
		||||
        self.wineWidget = QWidget()
 | 
			
		||||
        self.wineWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
 | 
			
		||||
        self.wineWidget.setObjectName("otherPage")
 | 
			
		||||
        layout = QVBoxLayout(self.wineWidget)
 | 
			
		||||
        layout.setContentsMargins(10, 18, 10, 10)
 | 
			
		||||
 | 
			
		||||
@@ -1061,21 +1354,20 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        tools_grid.setSpacing(6)
 | 
			
		||||
 | 
			
		||||
        tools = [
 | 
			
		||||
            ("winecfg", _("Wine Configuration")),
 | 
			
		||||
            ("regedit", _("Registry Editor")),
 | 
			
		||||
            ("control", _("Control Panel")),
 | 
			
		||||
            ("taskmgr", _("Task Manager")),
 | 
			
		||||
            ("explorer", _("File Explorer")),
 | 
			
		||||
            ("cmd", _("Command Prompt")),
 | 
			
		||||
            ("uninstaller", _("Uninstaller")),
 | 
			
		||||
            ("--winecfg", _("Wine Configuration")),
 | 
			
		||||
            ("--winereg", _("Registry Editor")),
 | 
			
		||||
            ("--winefile", _("File Explorer")),
 | 
			
		||||
            ("--winecmd", _("Command Prompt")),
 | 
			
		||||
            ("--wine_uninstaller", _("Uninstaller")),
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        for i, (_tool_cmd, tool_name) in enumerate(tools):
 | 
			
		||||
        for i, (tool_cmd, tool_name) in enumerate(tools):
 | 
			
		||||
            row = i // 3
 | 
			
		||||
            col = i % 3
 | 
			
		||||
            btn = AutoSizeButton(tool_name, update_size=False)
 | 
			
		||||
            btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
 | 
			
		||||
            btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
            btn.clicked.connect(lambda checked, t=tool_cmd: self.launch_generic_tool(t))
 | 
			
		||||
            tools_grid.addWidget(btn, row, col)
 | 
			
		||||
 | 
			
		||||
        for col in range(3):
 | 
			
		||||
@@ -1093,7 +1385,7 @@ class MainWindow(QMainWindow):
 | 
			
		||||
            (_("Load Prefix Backup"), self.load_prefix_backup),
 | 
			
		||||
            (_("Delete Compatibility Tool"), self.delete_compat_tool),
 | 
			
		||||
            (_("Delete Prefix"), self.delete_prefix),
 | 
			
		||||
            (_("Clear Prefix"), None),
 | 
			
		||||
            (_("Clear Prefix"), self.clear_prefix),
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        for i, (text, callback) in enumerate(additional_buttons):
 | 
			
		||||
@@ -1114,8 +1406,156 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        additional_grid.setContentsMargins(10, 6, 10, 0)
 | 
			
		||||
        layout.addStretch(1)
 | 
			
		||||
 | 
			
		||||
        self.wine_progress_bar = QProgressBar(self.wineWidget)
 | 
			
		||||
        self.wine_progress_bar.setStyleSheet(self.theme.PROGRESS_BAR_STYLE)
 | 
			
		||||
        self.wine_progress_bar.setMaximumWidth(200)
 | 
			
		||||
        self.wine_progress_bar.setTextVisible(True)
 | 
			
		||||
        self.wine_progress_bar.setVisible(False)
 | 
			
		||||
        self.wine_progress_bar.setRange(0, 0)
 | 
			
		||||
 | 
			
		||||
        wine_progress_layout = QHBoxLayout()
 | 
			
		||||
        wine_progress_layout.addStretch(1)
 | 
			
		||||
        wine_progress_layout.addWidget(self.wine_progress_bar)
 | 
			
		||||
        layout.addLayout(wine_progress_layout)
 | 
			
		||||
 | 
			
		||||
        self.stackedWidget.addWidget(self.wineWidget)
 | 
			
		||||
 | 
			
		||||
    def launch_generic_tool(self, cli_arg):
 | 
			
		||||
        wine = self.wineCombo.currentText()
 | 
			
		||||
        prefix = self.prefixCombo.currentText()
 | 
			
		||||
        if not wine or not prefix:
 | 
			
		||||
            return
 | 
			
		||||
        if not self.portproton_location or not self.start_sh:
 | 
			
		||||
            return
 | 
			
		||||
        start_sh = self.start_sh
 | 
			
		||||
        cmd = start_sh + ["cli", cli_arg, wine, prefix]
 | 
			
		||||
 | 
			
		||||
        # Показываем прогресс-бар перед запуском
 | 
			
		||||
        self.wine_progress_bar.setVisible(True)
 | 
			
		||||
        self.update_status_message.emit(_("Launching tool..."), 0)
 | 
			
		||||
 | 
			
		||||
        proc = QProcess(self)
 | 
			
		||||
        proc.finished.connect(lambda exitCode, exitStatus: self._on_wine_tool_finished(exitCode, cli_arg))
 | 
			
		||||
        proc.errorOccurred.connect(lambda error: self._on_wine_tool_error(error, cli_arg))
 | 
			
		||||
        proc.start(cmd[0], cmd[1:])
 | 
			
		||||
 | 
			
		||||
        if not proc.waitForStarted(5000):
 | 
			
		||||
            self.wine_progress_bar.setVisible(False)
 | 
			
		||||
            self.update_status_message.emit("", 0)
 | 
			
		||||
            QMessageBox.warning(self, _("Error"), _("Failed to start process."))
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        self._start_wine_process_monitor(cli_arg)
 | 
			
		||||
 | 
			
		||||
    def _start_wine_process_monitor(self, cli_arg):
 | 
			
		||||
        """Запускает таймер для мониторинга запуска Wine утилиты."""
 | 
			
		||||
        self.wine_monitor_timer = QTimer(self)
 | 
			
		||||
        self.wine_monitor_timer.setInterval(500)
 | 
			
		||||
        self.wine_monitor_timer.timeout.connect(lambda: self._check_wine_process(cli_arg))
 | 
			
		||||
        self.wine_monitor_timer.start()
 | 
			
		||||
 | 
			
		||||
    def _check_wine_process(self, cli_arg):
 | 
			
		||||
        """Проверяет, запустился ли целевой .exe процесс."""
 | 
			
		||||
        exe_map = {
 | 
			
		||||
            "--winecfg": "winecfg.exe",
 | 
			
		||||
            "--winereg": "regedit.exe",
 | 
			
		||||
            "--winefile": "winefile.exe",
 | 
			
		||||
            "--winecmd": "cmd.exe",
 | 
			
		||||
            "--wine_uninstaller": "uninstaller.exe",
 | 
			
		||||
        }
 | 
			
		||||
        target_exe = exe_map.get(cli_arg, "")
 | 
			
		||||
        if not target_exe:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # Проверяем процессы через psutil
 | 
			
		||||
        for proc in psutil.process_iter(attrs=["name"]):
 | 
			
		||||
            if proc.info["name"].lower() == target_exe.lower():
 | 
			
		||||
                # Процесс запустился — скрываем прогресс-бар и останавливаем мониторинг
 | 
			
		||||
                self.wine_progress_bar.setVisible(False)
 | 
			
		||||
                self.update_status_message.emit("", 0)
 | 
			
		||||
                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
 | 
			
		||||
                logger.info(f"Wine tool {target_exe} started successfully")
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
    def _on_wine_tool_finished(self, exitCode, cli_arg):
 | 
			
		||||
        """Обработчик завершения Wine утилиты."""
 | 
			
		||||
        self.wine_progress_bar.setVisible(False)
 | 
			
		||||
        self.update_status_message.emit("", 0)
 | 
			
		||||
        # Останавливаем мониторинг, если он активен
 | 
			
		||||
        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 exitCode == 0:
 | 
			
		||||
            logger.info(f"Wine tool {cli_arg} finished successfully")
 | 
			
		||||
        else:
 | 
			
		||||
            logger.warning(f"Wine tool {cli_arg} finished with exit code {exitCode}")
 | 
			
		||||
 | 
			
		||||
    def _on_wine_tool_error(self, error, cli_arg):
 | 
			
		||||
        """Обработчик ошибки запуска Wine утилиты."""
 | 
			
		||||
        self.wine_progress_bar.setVisible(False)
 | 
			
		||||
        self.update_status_message.emit("", 0)
 | 
			
		||||
        # Останавливаем мониторинг, если он активен
 | 
			
		||||
        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
 | 
			
		||||
        logger.error(f"Wine tool {cli_arg} error: {error}")
 | 
			
		||||
        QMessageBox.warning(self, _("Error"), f"Failed to launch tool: {error}")
 | 
			
		||||
 | 
			
		||||
    def clear_prefix(self):
 | 
			
		||||
        """Очищает префикс"""
 | 
			
		||||
        selected_prefix = self.prefixCombo.currentText()
 | 
			
		||||
        selected_wine = self.wineCombo.currentText()
 | 
			
		||||
 | 
			
		||||
        if not selected_prefix or not selected_wine:
 | 
			
		||||
            return
 | 
			
		||||
        if not self.portproton_location or not self.start_sh:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        reply = QMessageBox.question(
 | 
			
		||||
            self,
 | 
			
		||||
            _("Confirm Clear"),
 | 
			
		||||
            _("Are you sure you want to clear prefix '{}'?").format(selected_prefix),
 | 
			
		||||
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
 | 
			
		||||
            QMessageBox.StandardButton.No
 | 
			
		||||
        )
 | 
			
		||||
        if reply != QMessageBox.StandardButton.Yes:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        start_sh = self.start_sh
 | 
			
		||||
 | 
			
		||||
        self.wine_progress_bar.setVisible(True)
 | 
			
		||||
        self.update_status_message.emit(_("Clearing prefix..."), 0)
 | 
			
		||||
 | 
			
		||||
        self.clear_process = QProcess(self)
 | 
			
		||||
        self.clear_process.finished.connect(lambda exitCode, exitStatus: self._on_clear_prefix_finished(exitCode))
 | 
			
		||||
        self.clear_process.errorOccurred.connect(lambda error: self._on_clear_prefix_error(error))
 | 
			
		||||
        cmd = start_sh + ["cli", "--clear_pfx", selected_wine, selected_prefix]
 | 
			
		||||
        self.clear_process.start(cmd[0], cmd[1:])
 | 
			
		||||
 | 
			
		||||
        if not self.clear_process.waitForStarted(5000):
 | 
			
		||||
            self.wine_progress_bar.setVisible(False)
 | 
			
		||||
            self.update_status_message.emit("", 0)
 | 
			
		||||
            QMessageBox.warning(self, _("Error"), _("Failed to start prefix clear process."))
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
    def _on_clear_prefix_finished(self, exitCode):
 | 
			
		||||
        self.wine_progress_bar.setVisible(False)
 | 
			
		||||
        self.update_status_message.emit("", 0)
 | 
			
		||||
        if exitCode == 0:
 | 
			
		||||
            QMessageBox.information(self, _("Success"), _("Prefix cleared successfully."))
 | 
			
		||||
        else:
 | 
			
		||||
            QMessageBox.warning(self, _("Error"), _("Prefix clear failed with exit code {}.").format(exitCode))
 | 
			
		||||
 | 
			
		||||
    def _on_clear_prefix_error(self, error):
 | 
			
		||||
        self.wine_progress_bar.setVisible(False)
 | 
			
		||||
        self.update_status_message.emit("", 0)
 | 
			
		||||
        QMessageBox.warning(self, _("Error"), _("Failed to run clear prefix command: {}").format(error))
 | 
			
		||||
 | 
			
		||||
    def create_prefix_backup(self):
 | 
			
		||||
        selected_prefix = self.prefixCombo.currentText()
 | 
			
		||||
        if not selected_prefix:
 | 
			
		||||
@@ -1126,14 +1566,12 @@ class MainWindow(QMainWindow):
 | 
			
		||||
 | 
			
		||||
    def _perform_backup(self, backup_dir, prefix_name):
 | 
			
		||||
        os.makedirs(backup_dir, exist_ok=True)
 | 
			
		||||
        if not self.portproton_location:
 | 
			
		||||
            return
 | 
			
		||||
        start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
 | 
			
		||||
        if not os.path.exists(start_sh):
 | 
			
		||||
        if not self.portproton_location or not self.start_sh:
 | 
			
		||||
            return
 | 
			
		||||
        start_sh = self.start_sh
 | 
			
		||||
        self.backup_process = QProcess(self)
 | 
			
		||||
        self.backup_process.finished.connect(lambda exitCode, exitStatus: self._on_backup_finished(exitCode))
 | 
			
		||||
        cmd = [start_sh, "--backup-prefix", prefix_name, backup_dir]
 | 
			
		||||
        cmd = start_sh + ["--backup-prefix", prefix_name, backup_dir]
 | 
			
		||||
        self.backup_process.start(cmd[0], cmd[1:])
 | 
			
		||||
        if not self.backup_process.waitForStarted():
 | 
			
		||||
            QMessageBox.warning(self, _("Error"), _("Failed to start backup process."))
 | 
			
		||||
@@ -1146,14 +1584,12 @@ class MainWindow(QMainWindow):
 | 
			
		||||
    def _perform_restore(self, file_path):
 | 
			
		||||
        if not file_path or not os.path.exists(file_path):
 | 
			
		||||
            return
 | 
			
		||||
        if not self.portproton_location:
 | 
			
		||||
            return
 | 
			
		||||
        start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
 | 
			
		||||
        if not os.path.exists(start_sh):
 | 
			
		||||
        if not self.portproton_location or not self.start_sh:
 | 
			
		||||
            return
 | 
			
		||||
        start_sh = self.start_sh
 | 
			
		||||
        self.restore_process = QProcess(self)
 | 
			
		||||
        self.restore_process.finished.connect(lambda exitCode, exitStatus: self._on_restore_finished(exitCode))
 | 
			
		||||
        cmd = [start_sh, "--restore-prefix", file_path]
 | 
			
		||||
        cmd = start_sh + ["--restore-prefix", file_path]
 | 
			
		||||
        self.restore_process.start(cmd[0], cmd[1:])
 | 
			
		||||
        if not self.restore_process.waitForStarted():
 | 
			
		||||
            QMessageBox.warning(self, _("Error"), _("Failed to start restore process."))
 | 
			
		||||
@@ -1196,8 +1632,9 @@ class MainWindow(QMainWindow):
 | 
			
		||||
                QMessageBox.information(self, _("Success"), _("Prefix '{}' deleted.").format(selected_prefix))
 | 
			
		||||
                # обновляем список
 | 
			
		||||
                self.prefixCombo.clear()
 | 
			
		||||
                self.prefixes = [d for d in os.listdir(os.path.join(self.portproton_location, "data", "prefixes"))
 | 
			
		||||
                                 if os.path.isdir(os.path.join(self.portproton_location, "data", "prefixes", d))]
 | 
			
		||||
                prefixes_path = os.path.join(self.portproton_location, "data", "prefixes")
 | 
			
		||||
                self.prefixes = [d for d in os.listdir(prefixes_path)
 | 
			
		||||
                                if os.path.isdir(os.path.join(prefixes_path, d))]
 | 
			
		||||
                self.prefixCombo.addItems(self.prefixes)
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                QMessageBox.warning(self, _("Error"), _("Failed to delete prefix: {}").format(str(e)))
 | 
			
		||||
@@ -1338,7 +1775,22 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        self.gamesDisplayCombo.setCurrentIndex(idx)
 | 
			
		||||
        formLayout.addRow(self.gamesDisplayTitle, self.gamesDisplayCombo)
 | 
			
		||||
 | 
			
		||||
        # 4. Proxy settings
 | 
			
		||||
        # 4 Gamepad Type
 | 
			
		||||
        self.gamepadTypeCombo = QComboBox()
 | 
			
		||||
        self.gamepadTypeCombo.addItems(["Xbox", "PlayStation"])
 | 
			
		||||
        self.gamepadTypeCombo.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
        self.gamepadTypeCombo.setStyleSheet(self.theme.SETTINGS_COMBO_STYLE)
 | 
			
		||||
        self.gamepadTypeTitle = QLabel(_("Gamepad Type:"))
 | 
			
		||||
        self.gamepadTypeTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
 | 
			
		||||
        self.gamepadTypeTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
 | 
			
		||||
        current_type_str = read_gamepad_type()
 | 
			
		||||
        if current_type_str == "playstation":
 | 
			
		||||
            self.gamepadTypeCombo.setCurrentText("PlayStation")
 | 
			
		||||
        else:
 | 
			
		||||
            self.gamepadTypeCombo.setCurrentText("Xbox")
 | 
			
		||||
        formLayout.addRow(self.gamepadTypeTitle, self.gamepadTypeCombo)
 | 
			
		||||
 | 
			
		||||
        # 5. Proxy settings
 | 
			
		||||
        self.proxyUrlEdit = CustomLineEdit(self, theme=self.theme)
 | 
			
		||||
        self.proxyUrlEdit.setPlaceholderText(_("Proxy URL"))
 | 
			
		||||
        self.proxyUrlEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE)
 | 
			
		||||
@@ -1370,7 +1822,7 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        self.proxyPasswordTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
 | 
			
		||||
        formLayout.addRow(self.proxyPasswordTitle, self.proxyPasswordEdit)
 | 
			
		||||
 | 
			
		||||
        # 5. Fullscreen setting for application
 | 
			
		||||
        # 6. Fullscreen setting for application
 | 
			
		||||
        self.fullscreenCheckBox = QCheckBox(_("Launch Application in Fullscreen"))
 | 
			
		||||
        self.fullscreenCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
 | 
			
		||||
        self.fullscreenCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
@@ -1381,7 +1833,19 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        self.fullscreenCheckBox.setChecked(current_fullscreen)
 | 
			
		||||
        formLayout.addRow(self.fullscreenTitle, self.fullscreenCheckBox)
 | 
			
		||||
 | 
			
		||||
        # 6. Automatic fullscreen on gamepad connection
 | 
			
		||||
        # 7. Minimize to tray setting
 | 
			
		||||
        self.minimizeToTrayCheckBox = QCheckBox(_("Minimize to tray on close"))
 | 
			
		||||
        self.minimizeToTrayCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
 | 
			
		||||
        self.minimizeToTrayCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
        self.minimizeToTrayTitle = QLabel(_("Application Close Mode:"))
 | 
			
		||||
        self.minimizeToTrayTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
 | 
			
		||||
        self.minimizeToTrayTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
 | 
			
		||||
        current_minimize_to_tray = read_minimize_to_tray()
 | 
			
		||||
        self.minimizeToTrayCheckBox.setChecked(current_minimize_to_tray)
 | 
			
		||||
        self.minimizeToTrayCheckBox.toggled.connect(lambda checked: save_minimize_to_tray(checked))
 | 
			
		||||
        formLayout.addRow(self.minimizeToTrayTitle, self.minimizeToTrayCheckBox)
 | 
			
		||||
 | 
			
		||||
        # 8. Automatic fullscreen on gamepad connection
 | 
			
		||||
        self.autoFullscreenGamepadCheckBox = QCheckBox(_("Auto Fullscreen on Gamepad connected"))
 | 
			
		||||
        self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
 | 
			
		||||
        self.autoFullscreenGamepadCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
@@ -1393,7 +1857,7 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        self.autoFullscreenGamepadCheckBox.setChecked(current_auto_fullscreen)
 | 
			
		||||
        formLayout.addRow(self.autoFullscreenGamepadTitle, self.autoFullscreenGamepadCheckBox)
 | 
			
		||||
 | 
			
		||||
        # 7. Gamepad haptic feedback config
 | 
			
		||||
        # 9. Gamepad haptic feedback config
 | 
			
		||||
        self.gamepadRumbleCheckBox = QCheckBox(_("Gamepad haptic feedback"))
 | 
			
		||||
        self.gamepadRumbleCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
        self.gamepadRumbleCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
 | 
			
		||||
@@ -1404,10 +1868,10 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        self.gamepadRumbleCheckBox.setChecked(current_rumble_state)
 | 
			
		||||
        formLayout.addRow(self.gamepadRumbleTitle, self.gamepadRumbleCheckBox)
 | 
			
		||||
 | 
			
		||||
        # # 8. Legendary Authentication
 | 
			
		||||
        # # 9. Legendary Authentication
 | 
			
		||||
        # self.legendaryAuthButton = AutoSizeButton(
 | 
			
		||||
        #     _("Open Legendary Login"),
 | 
			
		||||
        #     icon=self.theme_manager.get_icon("login")
 | 
			
		||||
        #     icon=self.theme_manager.get_icon("login")self.theme_manager.get_icon("login")
 | 
			
		||||
        # )
 | 
			
		||||
        # self.legendaryAuthButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
 | 
			
		||||
        # self.legendaryAuthButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
@@ -1584,6 +2048,19 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        rumble_enabled = self.gamepadRumbleCheckBox.isChecked()
 | 
			
		||||
        save_rumble_config(rumble_enabled)
 | 
			
		||||
 | 
			
		||||
        gamepad_type_text = self.gamepadTypeCombo.currentText()
 | 
			
		||||
        gpad_type = "playstation" if gamepad_type_text == "PlayStation" else "xbox"
 | 
			
		||||
        save_gamepad_type(gpad_type)
 | 
			
		||||
 | 
			
		||||
        if hasattr(self, 'input_manager'):
 | 
			
		||||
            if gpad_type == "playstation":
 | 
			
		||||
                self.input_manager.gamepad_type = GamepadType.PLAYSTATION
 | 
			
		||||
            elif gpad_type == "xbox":
 | 
			
		||||
                self.input_manager.gamepad_type = GamepadType.XBOX
 | 
			
		||||
            else:
 | 
			
		||||
                self.input_manager.gamepad_type = GamepadType.UNKNOWN
 | 
			
		||||
            self.updateControlHints()
 | 
			
		||||
 | 
			
		||||
        for card in self.game_library_manager.game_card_cache.values():
 | 
			
		||||
            card.update_badge_visibility(filter_key)
 | 
			
		||||
 | 
			
		||||
@@ -1600,9 +2077,6 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        gamepad_connected = self.input_manager.find_gamepad() is not None
 | 
			
		||||
        if fullscreen or (auto_fullscreen_gamepad and gamepad_connected):
 | 
			
		||||
            self.showFullScreen()
 | 
			
		||||
        else:
 | 
			
		||||
            self.showNormal()
 | 
			
		||||
            self.resize(*read_window_geometry())
 | 
			
		||||
 | 
			
		||||
        self.statusBar().showMessage(_("Settings saved"), 3000)
 | 
			
		||||
 | 
			
		||||
@@ -1785,6 +2259,14 @@ class MainWindow(QMainWindow):
 | 
			
		||||
    def darkenColor(self, color, factor=200):
 | 
			
		||||
        return color.darker(factor)
 | 
			
		||||
 | 
			
		||||
    def open_exe_settings(self, exe_path):
 | 
			
		||||
        """Open the ExeSettingsDialog for the given executable."""
 | 
			
		||||
        if not os.path.exists(exe_path):
 | 
			
		||||
            QMessageBox.warning(self, _("Error"), _("Executable not found: {0}").format(exe_path))
 | 
			
		||||
            return
 | 
			
		||||
        dialog = ExeSettingsDialog(self, self.theme, exe_path)
 | 
			
		||||
        dialog.exec()
 | 
			
		||||
 | 
			
		||||
    def openGameDetailPage(self, name, description, cover_path=None, appid="", exec_line="", controller_support="", last_launch="", formatted_playtime="", protondb_tier="", game_source="", anticheat_status=""):
 | 
			
		||||
        detailPage = QWidget()
 | 
			
		||||
        self._animations = {}
 | 
			
		||||
@@ -2087,8 +2569,6 @@ class MainWindow(QMainWindow):
 | 
			
		||||
 | 
			
		||||
                clear_layout(hltbLayout)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
                has_data = False
 | 
			
		||||
 | 
			
		||||
                if main_story_time is not None:
 | 
			
		||||
@@ -2172,6 +2652,14 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        playButton.clicked.connect(lambda: self.toggleGame(exec_line, playButton))
 | 
			
		||||
        detailsLayout.addWidget(playButton, alignment=Qt.AlignmentFlag.AlignLeft)
 | 
			
		||||
 | 
			
		||||
        # Settings button
 | 
			
		||||
        settings_icon = self.theme_manager.get_icon("settings")
 | 
			
		||||
        settings_button = AutoSizeButton(_("Settings"), icon=settings_icon)
 | 
			
		||||
        settings_button.setFixedSize(120, 40)
 | 
			
		||||
        settings_button.setStyleSheet(self.theme.PLAY_BUTTON_STYLE)
 | 
			
		||||
        settings_button.clicked.connect(lambda: self.open_exe_settings(file_to_check))
 | 
			
		||||
        detailsLayout.addWidget(settings_button, alignment=Qt.AlignmentFlag.AlignLeft)
 | 
			
		||||
 | 
			
		||||
        contentFrameLayout.addWidget(detailsWidget)
 | 
			
		||||
        mainLayout.addStretch()
 | 
			
		||||
 | 
			
		||||
@@ -2392,14 +2880,9 @@ class MainWindow(QMainWindow):
 | 
			
		||||
            else:
 | 
			
		||||
                # Запускаем игру через PortProton
 | 
			
		||||
                env_vars = os.environ.copy()
 | 
			
		||||
                env_vars['START_FROM_STEAM'] = '1'
 | 
			
		||||
                env_vars['LEGENDARY_CONFIG_PATH'] = self.legendary_config_path
 | 
			
		||||
                env_vars['PROCESS_LOG'] = '1'
 | 
			
		||||
 | 
			
		||||
                wrapper = "flatpak run ru.linux_gaming.PortProton"
 | 
			
		||||
                if self.portproton_location is not None and ".var" not in self.portproton_location:
 | 
			
		||||
                    start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
 | 
			
		||||
                    wrapper = start_sh
 | 
			
		||||
                wrapper = self.start_sh or ""
 | 
			
		||||
 | 
			
		||||
                cmd = [wrapper, game_exe]
 | 
			
		||||
 | 
			
		||||
@@ -2493,13 +2976,6 @@ class MainWindow(QMainWindow):
 | 
			
		||||
            exe_name = os.path.splitext(current_exe)[0]
 | 
			
		||||
            env_vars = os.environ.copy()
 | 
			
		||||
 | 
			
		||||
            if entry_exec_split[0] == "env" and len(entry_exec_split) > 1 and 'data/scripts/start.sh' in entry_exec_split[1]:
 | 
			
		||||
                env_vars['START_FROM_STEAM'] = '1'
 | 
			
		||||
                env_vars['PROCESS_LOG'] = '1'
 | 
			
		||||
            elif entry_exec_split[0] == "flatpak":
 | 
			
		||||
                env_vars['START_FROM_STEAM'] = '1'
 | 
			
		||||
                env_vars['PROCESS_LOG'] = '1'
 | 
			
		||||
 | 
			
		||||
            # Запускаем игру
 | 
			
		||||
            try:
 | 
			
		||||
                process = subprocess.Popen(entry_exec_split, env=env_vars, shell=False, preexec_fn=os.setsid)
 | 
			
		||||
@@ -2521,12 +2997,38 @@ class MainWindow(QMainWindow):
 | 
			
		||||
                logger.error(f"Failed to launch game {exe_name}: {e}")
 | 
			
		||||
                QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e)))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def closeEvent(self, event):
 | 
			
		||||
        """Обработчик закрытия окна: сворачивает приложение в трей, если не требуется принудительный выход."""
 | 
			
		||||
        if hasattr(self, 'is_exiting') and self.is_exiting:
 | 
			
		||||
            # Принудительное закрытие: завершаем процессы и приложение
 | 
			
		||||
            for proc in self.game_processes:
 | 
			
		||||
        """Обработчик закрытия окна: проверяет настройку minimize_to_tray.
 | 
			
		||||
        Если True — сворачиваем в трей (по умолчанию). Иначе — полностью закрываем.
 | 
			
		||||
        """
 | 
			
		||||
        minimize_to_tray = read_minimize_to_tray()
 | 
			
		||||
 | 
			
		||||
        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)
 | 
			
		||||
@@ -2536,38 +3038,36 @@ class MainWindow(QMainWindow):
 | 
			
		||||
                        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}")
 | 
			
		||||
                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.isActive():
 | 
			
		||||
                self.games_load_timer.stop()
 | 
			
		||||
            if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer.isActive():
 | 
			
		||||
                self.settingsDebounceTimer.stop()
 | 
			
		||||
            if hasattr(self, 'searchDebounceTimer') 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
 | 
			
		||||
        # Универсальная остановка и удаление таймеров
 | 
			
		||||
        timers = [
 | 
			
		||||
            "games_load_timer",
 | 
			
		||||
            "settingsDebounceTimer",
 | 
			
		||||
            "searchDebounceTimer",
 | 
			
		||||
            "checkProcessTimer",
 | 
			
		||||
            "wine_monitor_timer",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
            # Сохраняем настройки окна
 | 
			
		||||
            if not read_fullscreen_config():
 | 
			
		||||
                logger.debug(f"Saving window geometry: {self.width()}x{self.height()}")
 | 
			
		||||
                save_window_geometry(self.width(), self.height())
 | 
			
		||||
            save_card_size(self.card_width)
 | 
			
		||||
 | 
			
		||||
            event.accept()
 | 
			
		||||
        else:
 | 
			
		||||
            # Сворачиваем в трей вместо закрытия
 | 
			
		||||
            self.hide()
 | 
			
		||||
            event.ignore()
 | 
			
		||||
        for tname in timers:
 | 
			
		||||
            timer = getattr(self, tname, None)
 | 
			
		||||
            if timer and timer.isActive():
 | 
			
		||||
                timer.stop()
 | 
			
		||||
            if timer:
 | 
			
		||||
                timer.deleteLater()
 | 
			
		||||
                setattr(self, tname, None)
 | 
			
		||||
 
 | 
			
		||||
@@ -4,12 +4,18 @@ import orjson
 | 
			
		||||
import requests
 | 
			
		||||
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):
 | 
			
		||||
    """
 | 
			
		||||
@@ -52,7 +58,11 @@ class PortProtonAPI:
 | 
			
		||||
        self.xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
 | 
			
		||||
        self.custom_data_dir = os.path.join(self.xdg_data_home, "PortProtonQt", "custom_data")
 | 
			
		||||
        os.makedirs(self.custom_data_dir, exist_ok=True)
 | 
			
		||||
        self.portproton_location = get_portproton_location()
 | 
			
		||||
        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)
 | 
			
		||||
@@ -68,40 +78,6 @@ class PortProtonAPI:
 | 
			
		||||
            logger.debug(f"Failed to check file at {url}: {e}")
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
    def download_game_assets(self, exe_name: str, timeout: int = 5) -> dict[str, str | None]:
 | 
			
		||||
        game_dir = self._get_game_dir(exe_name)
 | 
			
		||||
        results: dict[str, str | None] = {"cover": None, "metadata": None}
 | 
			
		||||
        cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"]
 | 
			
		||||
        cover_url_base = f"{self.base_url}/{exe_name}/cover"
 | 
			
		||||
        metadata_url = f"{self.base_url}/{exe_name}/metadata.txt"
 | 
			
		||||
 | 
			
		||||
        for ext in cover_extensions:
 | 
			
		||||
            cover_url = f"{cover_url_base}{ext}"
 | 
			
		||||
            if self._check_file_exists(cover_url, timeout):
 | 
			
		||||
                local_cover_path = os.path.join(game_dir, f"cover{ext}")
 | 
			
		||||
                result = self.downloader.download(cover_url, local_cover_path, timeout=timeout)
 | 
			
		||||
                if result:
 | 
			
		||||
                    results["cover"] = result
 | 
			
		||||
                    logger.info(f"Downloaded cover for {exe_name} to {result}")
 | 
			
		||||
                    break
 | 
			
		||||
                else:
 | 
			
		||||
                    logger.error(f"Failed to download cover for {exe_name} from {cover_url}")
 | 
			
		||||
            else:
 | 
			
		||||
                logger.debug(f"No cover found for {exe_name} with extension {ext}")
 | 
			
		||||
 | 
			
		||||
        if self._check_file_exists(metadata_url, timeout):
 | 
			
		||||
            local_metadata_path = os.path.join(game_dir, "metadata.txt")
 | 
			
		||||
            result = self.downloader.download(metadata_url, local_metadata_path, timeout=timeout)
 | 
			
		||||
            if result:
 | 
			
		||||
                results["metadata"] = result
 | 
			
		||||
                logger.info(f"Downloaded metadata for {exe_name} to {result}")
 | 
			
		||||
            else:
 | 
			
		||||
                logger.error(f"Failed to download metadata for {exe_name} from {metadata_url}")
 | 
			
		||||
        else:
 | 
			
		||||
            logger.debug(f"No metadata found for {exe_name}")
 | 
			
		||||
 | 
			
		||||
        return results
 | 
			
		||||
 | 
			
		||||
    def download_game_assets_async(self, exe_name: str, timeout: int = 5, callback: Callable[[dict[str, str | None]], None] | None = None) -> None:
 | 
			
		||||
        game_dir = self._get_game_dir(exe_name)
 | 
			
		||||
        cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"]
 | 
			
		||||
@@ -163,6 +139,236 @@ class PortProtonAPI:
 | 
			
		||||
            if callback:
 | 
			
		||||
                callback(results)
 | 
			
		||||
 | 
			
		||||
    def download_autoinstall_cover_async(self, exe_name: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None) -> None:
 | 
			
		||||
        """Download only autoinstall cover image (PNG only, no metadata)."""
 | 
			
		||||
        xdg_data_home = os.getenv("XDG_DATA_HOME",
 | 
			
		||||
                                os.path.join(os.path.expanduser("~"), ".local", "share"))
 | 
			
		||||
        autoinstall_root = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall")
 | 
			
		||||
        user_game_folder = os.path.join(autoinstall_root, exe_name)
 | 
			
		||||
 | 
			
		||||
        if not os.path.isdir(user_game_folder):
 | 
			
		||||
            try:
 | 
			
		||||
                os.mkdir(user_game_folder)
 | 
			
		||||
            except FileExistsError:
 | 
			
		||||
                pass
 | 
			
		||||
 | 
			
		||||
        cover_url = f"{self.base_url}/{exe_name}/cover.png"
 | 
			
		||||
        local_cover_path = os.path.join(user_game_folder, "cover.png")
 | 
			
		||||
 | 
			
		||||
        def on_cover_downloaded(local_path: str | None):
 | 
			
		||||
            if local_path:
 | 
			
		||||
                logger.info(f"Async autoinstall cover downloaded for {exe_name}: {local_path}")
 | 
			
		||||
            else:
 | 
			
		||||
                logger.debug(f"No autoinstall cover downloaded for {exe_name}")
 | 
			
		||||
            if callback:
 | 
			
		||||
                callback(local_path)
 | 
			
		||||
 | 
			
		||||
        if self._check_file_exists(cover_url, timeout):
 | 
			
		||||
            self.downloader.download_async(
 | 
			
		||||
                cover_url,
 | 
			
		||||
                local_cover_path,
 | 
			
		||||
                timeout=timeout,
 | 
			
		||||
                callback=on_cover_downloaded
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
            logger.debug(f"No autoinstall cover found for {exe_name}")
 | 
			
		||||
            if callback:
 | 
			
		||||
                callback(None)
 | 
			
		||||
 | 
			
		||||
    def parse_autoinstall_script(self, file_path: str) -> tuple[str | None, str | None]:
 | 
			
		||||
        """Extract display_name from # name comment and exe_name from autoinstall bash script."""
 | 
			
		||||
        try:
 | 
			
		||||
            with open(file_path, encoding='utf-8') as f:
 | 
			
		||||
                content = f.read()
 | 
			
		||||
 | 
			
		||||
            # Skip emulators
 | 
			
		||||
            if re.search(r'#\s*type\s*:\s*emulators', content, re.IGNORECASE):
 | 
			
		||||
                return None, None
 | 
			
		||||
 | 
			
		||||
            display_name = None
 | 
			
		||||
            exe_name = None
 | 
			
		||||
 | 
			
		||||
            # Extract display_name from "# name:" comment
 | 
			
		||||
            name_match = re.search(r'#\s*name\s*:\s*(.+)', content, re.IGNORECASE)
 | 
			
		||||
            if name_match:
 | 
			
		||||
                display_name = name_match.group(1).strip()
 | 
			
		||||
 | 
			
		||||
            # --- pw_create_unique_exe ---
 | 
			
		||||
            pw_match = re.search(r'pw_create_unique_exe(?:\s+["\']([^"\']+)["\'])?', content)
 | 
			
		||||
            if pw_match:
 | 
			
		||||
                arg = pw_match.group(1)
 | 
			
		||||
                if arg:
 | 
			
		||||
                    exe_name = arg.strip()
 | 
			
		||||
                    if not exe_name.lower().endswith(".exe"):
 | 
			
		||||
                        exe_name += ".exe"
 | 
			
		||||
                else:
 | 
			
		||||
                    export_match = re.search(
 | 
			
		||||
                        r'export\s+PORTWINE_CREATE_SHORTCUT_NAME\s*=\s*["\']([^"\']+)["\']',
 | 
			
		||||
                        content, re.IGNORECASE)
 | 
			
		||||
                    if export_match:
 | 
			
		||||
                        exe_name = f"{export_match.group(1).strip()}.exe"
 | 
			
		||||
 | 
			
		||||
            else:
 | 
			
		||||
                portwine_match = None
 | 
			
		||||
                for line in content.splitlines():
 | 
			
		||||
                    stripped = line.strip()
 | 
			
		||||
                    if stripped.startswith("#"):
 | 
			
		||||
                        continue
 | 
			
		||||
                    if "portwine_exe" in stripped and "=" in stripped:
 | 
			
		||||
                        portwine_match = stripped
 | 
			
		||||
                        break
 | 
			
		||||
 | 
			
		||||
                if portwine_match:
 | 
			
		||||
                    exe_expr = portwine_match.split("=", 1)[1].strip().strip("'\" ")
 | 
			
		||||
                    exe_candidates = re.findall(r'[-\w\s/\\\.]+\.exe', exe_expr)
 | 
			
		||||
                    if exe_candidates:
 | 
			
		||||
                        exe_name = os.path.basename(exe_candidates[-1].strip())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
            # Fallback
 | 
			
		||||
            if not display_name and exe_name:
 | 
			
		||||
                display_name = exe_name
 | 
			
		||||
 | 
			
		||||
            return display_name, exe_name
 | 
			
		||||
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to parse {file_path}: {e}")
 | 
			
		||||
            return None, None
 | 
			
		||||
 | 
			
		||||
    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):
 | 
			
		||||
            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
 | 
			
		||||
 | 
			
		||||
                scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
 | 
			
		||||
                if not scripts:
 | 
			
		||||
                    self.finished.emit(games)
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
                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)
 | 
			
		||||
 | 
			
		||||
                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."""
 | 
			
		||||
        if self._topics_data is not None:
 | 
			
		||||
 
 | 
			
		||||
@@ -13,7 +13,7 @@ from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.localization import get_steam_language
 | 
			
		||||
from portprotonqt.downloader import Downloader
 | 
			
		||||
from portprotonqt.dialogs import generate_thumbnail
 | 
			
		||||
from portprotonqt.config_utils import get_portproton_location
 | 
			
		||||
from portprotonqt.config_utils import get_portproton_location, get_portproton_start_command
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
import re
 | 
			
		||||
import shutil
 | 
			
		||||
@@ -23,6 +23,7 @@ import requests
 | 
			
		||||
import random
 | 
			
		||||
import base64
 | 
			
		||||
import glob
 | 
			
		||||
import urllib.parse
 | 
			
		||||
 | 
			
		||||
downloader = Downloader()
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
@@ -411,6 +412,39 @@ def save_app_details(app_id, data):
 | 
			
		||||
    with open(cache_file, "wb") as f:
 | 
			
		||||
        f.write(orjson.dumps(data))
 | 
			
		||||
 | 
			
		||||
def fetch_sgdb_cover(game_name: str) -> str:
 | 
			
		||||
    """
 | 
			
		||||
    Fetch a cover image URL from steamgrid.usebottles.com for the given game.
 | 
			
		||||
    The API returns a single string (quoted URL).
 | 
			
		||||
    """
 | 
			
		||||
    try:
 | 
			
		||||
        encoded = urllib.parse.quote(game_name)
 | 
			
		||||
        url = f"https://steamgrid.usebottles.com/api/search/{encoded}"
 | 
			
		||||
        resp = requests.get(url, timeout=5)
 | 
			
		||||
        if resp.status_code != 200:
 | 
			
		||||
            logger.warning("SGDB request failed for %s: %s", game_name, resp.status_code)
 | 
			
		||||
            return ""
 | 
			
		||||
        text = resp.text.strip()
 | 
			
		||||
        # Убираем возможные кавычки вокруг строки
 | 
			
		||||
        if text.startswith('"') and text.endswith('"'):
 | 
			
		||||
            text = text[1:-1]
 | 
			
		||||
        if text:
 | 
			
		||||
            logger.info("Fetched SGDB cover for %s: %s", game_name, text)
 | 
			
		||||
        return text
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.warning("Failed to fetch SGDB cover for %s: %s", game_name, e)
 | 
			
		||||
    return ""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def check_url_exists(url: str) -> bool:
 | 
			
		||||
    """Check whether a URL returns HTTP 200."""
 | 
			
		||||
    try:
 | 
			
		||||
        r = requests.head(url, timeout=3)
 | 
			
		||||
        return r.status_code == 200
 | 
			
		||||
    except Exception:
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]):
 | 
			
		||||
    """
 | 
			
		||||
    Asynchronously fetches detailed app info from Steam API.
 | 
			
		||||
@@ -629,6 +663,11 @@ def get_full_steam_game_info_async(appid: int, callback: Callable[[dict], None])
 | 
			
		||||
        title = decode_text(app_info.get("name", ""))
 | 
			
		||||
        description = decode_text(app_info.get("short_description", ""))
 | 
			
		||||
        cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
 | 
			
		||||
        if not check_url_exists(cover):
 | 
			
		||||
            logger.info("Steam cover not found for %s, trying SGDB", title)
 | 
			
		||||
            alt_cover = fetch_sgdb_cover(title)
 | 
			
		||||
            if alt_cover:
 | 
			
		||||
                cover = alt_cover
 | 
			
		||||
 | 
			
		||||
        def on_protondb_tier(tier: str):
 | 
			
		||||
            def on_anticheat_status(anticheat_status: str):
 | 
			
		||||
@@ -722,12 +761,15 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
 | 
			
		||||
        game_name = desktop_name or exe_name.capitalize()
 | 
			
		||||
 | 
			
		||||
        if not matching_app:
 | 
			
		||||
            cover = fetch_sgdb_cover(game_name) or ""
 | 
			
		||||
            logger.info("Using SGDB cover for non-Steam game '%s': %s", game_name, cover)
 | 
			
		||||
 | 
			
		||||
            def on_anticheat_status(anticheat_status: str):
 | 
			
		||||
                callback({
 | 
			
		||||
                    "appid": "",
 | 
			
		||||
                    "name": decode_text(game_name),
 | 
			
		||||
                    "description": "",
 | 
			
		||||
                    "cover": "",
 | 
			
		||||
                    "cover": cover,
 | 
			
		||||
                    "controller_support": "",
 | 
			
		||||
                    "protondb_tier": "",
 | 
			
		||||
                    "steam_game": "false",
 | 
			
		||||
@@ -758,6 +800,11 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
 | 
			
		||||
            title = decode_text(app_info.get("name", game_name))
 | 
			
		||||
            description = decode_text(app_info.get("short_description", ""))
 | 
			
		||||
            cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
 | 
			
		||||
            if not check_url_exists(cover):
 | 
			
		||||
                logger.info("Steam cover not found for %s, trying SGDB", title)
 | 
			
		||||
                alt_cover = fetch_sgdb_cover(title)
 | 
			
		||||
                if alt_cover:
 | 
			
		||||
                    cover = alt_cover
 | 
			
		||||
            controller_support = app_info.get("controller_support", "")
 | 
			
		||||
 | 
			
		||||
            def on_protondb_tier(tier: str):
 | 
			
		||||
@@ -957,7 +1004,8 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
 | 
			
		||||
        return (False, f"Executable file not found: {exe_path}")
 | 
			
		||||
 | 
			
		||||
    portproton_dir = get_portproton_location()
 | 
			
		||||
    if not portproton_dir:
 | 
			
		||||
    start_sh = get_portproton_start_command()
 | 
			
		||||
    if not portproton_dir or not start_sh:
 | 
			
		||||
        logger.error("PortProton directory not found")
 | 
			
		||||
        return (False, "PortProton directory not found")
 | 
			
		||||
 | 
			
		||||
@@ -966,17 +1014,12 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
 | 
			
		||||
 | 
			
		||||
    safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', game_name.strip())
 | 
			
		||||
    script_path = os.path.join(steam_scripts_dir, f"{safe_game_name}.sh")
 | 
			
		||||
    start_sh_path = os.path.join(portproton_dir, "data", "scripts", "start.sh")
 | 
			
		||||
 | 
			
		||||
    if not os.path.exists(start_sh_path):
 | 
			
		||||
        logger.error(f"start.sh not found at {start_sh_path}")
 | 
			
		||||
        return (False, f"start.sh not found at {start_sh_path}")
 | 
			
		||||
 | 
			
		||||
    if not os.path.exists(script_path):
 | 
			
		||||
        script_content = f"""#!/usr/bin/env bash
 | 
			
		||||
export LD_PRELOAD=
 | 
			
		||||
export START_FROM_STEAM=1
 | 
			
		||||
"{start_sh_path}" "{exe_path}" "$@"
 | 
			
		||||
"{start_sh}" "{exe_path}" "$@"
 | 
			
		||||
"""
 | 
			
		||||
        try:
 | 
			
		||||
            with open(script_path, "w", encoding="utf-8") as f:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/icons/settings.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8.0005 1c-0.38761 0-0.77522 0.0327-1.1588 0.0979-0.16351 0.0281-0.30273 0.13627-0.37209 0.28935l-0.39088 0.86264c-0.49378 0.16682-0.96454 0.39759-1.4007 0.68616 2.5e-4 0-0.90672-0.2272-0.90672-0.2272-0.161-0.0403-0.33098 3e-3 -0.45442 0.11569-0.57867 0.5285-1.0672 1.1514-1.4451 1.8432-0.0804 0.14721-0.0841 0.32549-0.01 0.47628l0.41938 0.84865c-0.17954 0.49666-0.29567 1.0147-0.346 1.5417l-0.73995 0.57946c-0.13121 0.10289-0.20407 0.26514-0.19431 0.4335 0.0453 0.78981 0.21961 1.5666 0.51558 2.2983 0.0631 0.15587 0.1978 0.27003 0.36005 0.30467l0.91397 0.19559c0.26993 0.45234 0.59572 0.86802 0.96931 1.2363l-0.0161 0.94973c-3e-3 0.16861 0.0766 0.32755 0.21183 0.42484 0.63551 0.45642 1.3414 0.80207 2.0884 1.0229 0.15926 0.0471 0.33077 0.0109 0.45872-0.0963l0.72016-0.60485c0.51582 0.0674 1.0384 0.0674 1.5544 0l0.72016 0.60485c0.12796 0.10722 0.29946 0.14343 0.45872 0.0963 0.74693-0.22083 1.4528-0.56648 2.0883-1.0229 0.13521-0.0973 0.21465-0.25623 0.21189-0.42484l-0.0161-0.94973c0.37359-0.36829 0.69939-0.78372 0.96932-1.2363l0.91396-0.19559c0.16226-0.0347 0.29695-0.1488 0.36005-0.30467 0.29597-0.73174 0.47026-1.5085 0.51558-2.2983 0.01-0.16836-0.0631-0.33061-0.1943-0.4335l-0.73996-0.57946c-0.0501-0.52671-0.16652-1.045-0.34606-1.5417l0.41944-0.84865c0.0746-0.15079 0.0709-0.32907-0.01-0.47628-0.37785-0.69176-0.86638-1.3147-1.445-1.8432-0.12345-0.11258-0.29343-0.15594-0.45443-0.11569l-0.90697 0.2272c-0.43594-0.28857-0.9067-0.51908-1.4005-0.68616l-0.39088-0.86264c-0.0694-0.15308-0.20858-0.26132-0.37209-0.28935-0.38361-0.0653-0.77121-0.0979-1.1588-0.0979zm0 4.1365a2.8152 2.8635 0 0 1 2.8152 2.8636 2.8152 2.8635 0 0 1-2.8152 2.8635 2.8152 2.8635 0 0 1-2.8152-2.8635 2.8152 2.8635 0 0 1 2.8152-2.8636z" fill="#fff" stroke-width=".25254"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 1.8 KiB  | 
| 
		 After Width: | Height: | Size: 232 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								portprotonqt/themes/standart/images/screenshots/Библиотека.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 225 KiB  | 
| 
		 Before Width: | Height: | Size: 1.3 MiB  | 
							
								
								
									
										
											BIN
										
									
								
								portprotonqt/themes/standart/images/screenshots/Карточка.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 70 KiB  | 
| 
		 Before Width: | Height: | Size: 364 KiB  | 
| 
		 Before Width: | Height: | Size: 430 KiB  | 
| 
		 After Width: | Height: | Size: 238 KiB  | 
| 
		 Before Width: | Height: | Size: 1.3 MiB  | 
| 
		 After Width: | Height: | Size: 61 KiB  | 
| 
		 After Width: | Height: | Size: 38 KiB  | 
| 
		 Before Width: | Height: | Size: 104 KiB  | 
| 
		 Before Width: | Height: | Size: 1.0 MiB  | 
							
								
								
									
										
											BIN
										
									
								
								portprotonqt/themes/standart/images/screenshots/Темы.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 93 KiB  | 
@@ -1,7 +1,8 @@
 | 
			
		||||
from typing import cast
 | 
			
		||||
from typing import cast, Any
 | 
			
		||||
from PySide6.QtWidgets import (QFrame, QVBoxLayout, QPushButton, QGridLayout,
 | 
			
		||||
                               QSizePolicy, QWidget, QLineEdit)
 | 
			
		||||
from PySide6.QtCore import Qt, Signal, QProcess
 | 
			
		||||
from PySide6.QtCore import Qt, Signal, QProcess, QSize
 | 
			
		||||
from PySide6.QtGui import QPixmap, QIcon
 | 
			
		||||
from portprotonqt.keyboard_layouts import keyboard_layouts
 | 
			
		||||
from portprotonqt.theme_manager import ThemeManager
 | 
			
		||||
from portprotonqt.config_utils import read_theme_from_config
 | 
			
		||||
@@ -43,6 +44,18 @@ class VirtualKeyboard(QFrame):
 | 
			
		||||
        self.margins = 10
 | 
			
		||||
        self.num_cols = 14
 | 
			
		||||
 | 
			
		||||
        # Find input_manager and main_window
 | 
			
		||||
        self.input_manager: Any = None
 | 
			
		||||
        self.main_window: Any = None
 | 
			
		||||
        parent_widget: QWidget | None = self._parent
 | 
			
		||||
        while parent_widget:
 | 
			
		||||
            if hasattr(parent_widget, 'input_manager'):
 | 
			
		||||
                self.input_manager = cast(Any, parent_widget).input_manager
 | 
			
		||||
                self.main_window = cast(Any, parent_widget)
 | 
			
		||||
            parent_widget = cast(QWidget | None, parent_widget.parent())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        self.current_theme_name = read_theme_from_config()
 | 
			
		||||
        self.initUI()
 | 
			
		||||
        self.hide()
 | 
			
		||||
 | 
			
		||||
@@ -119,6 +132,34 @@ class VirtualKeyboard(QFrame):
 | 
			
		||||
        self.buttons: dict[str, QPushButton] = {}
 | 
			
		||||
        self.update_keyboard()
 | 
			
		||||
 | 
			
		||||
    def set_gamepad_icon(self, button, icon_type, gtype=''):
 | 
			
		||||
        """Set gamepad icon on button based on type"""
 | 
			
		||||
        if icon_type in ['back', 'add_game']:
 | 
			
		||||
            icon_name = self.main_window.get_button_icon(icon_type, gtype)
 | 
			
		||||
        else:  # nav left/right
 | 
			
		||||
            if icon_type in ['left', 'right']:
 | 
			
		||||
                direction = icon_type
 | 
			
		||||
                icon_name = self.main_window.get_nav_icon(direction, gtype)
 | 
			
		||||
            else:
 | 
			
		||||
                direction = 'left' if icon_type == 'left' else 'right'
 | 
			
		||||
                icon_name = self.main_window.get_nav_icon(direction, gtype)
 | 
			
		||||
 | 
			
		||||
        icon_path = theme_manager.get_theme_image(icon_name, self.current_theme_name)
 | 
			
		||||
        pixmap = QPixmap()
 | 
			
		||||
        if icon_path:
 | 
			
		||||
            pixmap.load(str(icon_path))
 | 
			
		||||
        if not pixmap.isNull():
 | 
			
		||||
            button.setIcon(QIcon(pixmap))
 | 
			
		||||
            button.setIconSize(QSize(20, 20))
 | 
			
		||||
            return
 | 
			
		||||
        else:
 | 
			
		||||
            # Fallback to placeholder
 | 
			
		||||
            placeholder = theme_manager.get_theme_image("placeholder", self.current_theme_name)
 | 
			
		||||
            if placeholder:
 | 
			
		||||
                button.setIcon(QIcon(placeholder))
 | 
			
		||||
                button.setIconSize(QSize(20, 20))
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
    def update_keyboard(self):
 | 
			
		||||
        coords = self._save_focused_coords()
 | 
			
		||||
 | 
			
		||||
@@ -151,6 +192,9 @@ class VirtualKeyboard(QFrame):
 | 
			
		||||
                    button.setCheckable(True)
 | 
			
		||||
                    button.setChecked(self.shift_pressed)
 | 
			
		||||
                    button.clicked.connect(lambda checked: self.on_shift_click(checked))
 | 
			
		||||
                    # Add gamepad icon for Shift (RB/R)
 | 
			
		||||
                    gtype = self.input_manager.gamepad_type
 | 
			
		||||
                    self.set_gamepad_icon(button, 'right', gtype)
 | 
			
		||||
                else:
 | 
			
		||||
                    button.clicked.connect(lambda checked=False, k=key: self.on_button_click(k))
 | 
			
		||||
 | 
			
		||||
@@ -163,6 +207,9 @@ class VirtualKeyboard(QFrame):
 | 
			
		||||
        shift.setCheckable(True)
 | 
			
		||||
        shift.setChecked(self.shift_pressed)
 | 
			
		||||
        shift.clicked.connect(lambda checked: self.on_shift_click(checked))
 | 
			
		||||
        # Add gamepad icon for Shift (RB/R)
 | 
			
		||||
        gtype = self.input_manager.gamepad_type
 | 
			
		||||
        self.set_gamepad_icon(shift, 'right', gtype)
 | 
			
		||||
        self.keyboard_layout.addWidget(shift, 3, 11, 1, 3)
 | 
			
		||||
 | 
			
		||||
        button = QPushButton('CAPS')
 | 
			
		||||
@@ -179,6 +226,9 @@ class VirtualKeyboard(QFrame):
 | 
			
		||||
        backspace.setFixedSize(fixed_w, fixed_h)
 | 
			
		||||
        backspace.pressed.connect(self.on_backspace_pressed)
 | 
			
		||||
        backspace.released.connect(self.stop_backspace_repeat)
 | 
			
		||||
        # Add gamepad icon for Backspace (X/Triangle)
 | 
			
		||||
        gtype = self.input_manager.gamepad_type
 | 
			
		||||
        self.set_gamepad_icon(backspace, 'add_game', gtype)
 | 
			
		||||
        self.keyboard_layout.addWidget(backspace, 0, 13, 1, 1)
 | 
			
		||||
 | 
			
		||||
        enter = QPushButton('Enter')
 | 
			
		||||
@@ -189,6 +239,9 @@ class VirtualKeyboard(QFrame):
 | 
			
		||||
        lang = QPushButton('🌐')
 | 
			
		||||
        lang.setFixedSize(fixed_w, fixed_h)
 | 
			
		||||
        lang.clicked.connect(self.on_lang_click)
 | 
			
		||||
        # Add gamepad icon for Lang (LB/L)
 | 
			
		||||
        gtype = self.input_manager.gamepad_type
 | 
			
		||||
        self.set_gamepad_icon(lang, 'left', gtype)
 | 
			
		||||
        self.keyboard_layout.addWidget(lang, 4, 0, 1, 1)
 | 
			
		||||
 | 
			
		||||
        clear = QPushButton('Clear')
 | 
			
		||||
@@ -219,6 +272,9 @@ class VirtualKeyboard(QFrame):
 | 
			
		||||
        hide_button = QPushButton('Hide')
 | 
			
		||||
        hide_button.setFixedSize(fixed_w * 2 + self.spacing, fixed_h)
 | 
			
		||||
        hide_button.clicked.connect(self.hide)
 | 
			
		||||
        # Add gamepad icon for Hide (B/Circle)
 | 
			
		||||
        gtype = self.input_manager.gamepad_type
 | 
			
		||||
        self.set_gamepad_icon(hide_button, 'back', gtype)
 | 
			
		||||
        self.keyboard_layout.addWidget(hide_button, 4, 12, 1, 2)
 | 
			
		||||
 | 
			
		||||
        if coords:
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 | 
			
		||||
 | 
			
		||||
[project]
 | 
			
		||||
name = "portprotonqt"
 | 
			
		||||
version = "0.1.6"
 | 
			
		||||
version = "0.1.8"
 | 
			
		||||
description = "A project to rewrite PortProton (PortWINE) using PySide"
 | 
			
		||||
readme = "README.md"
 | 
			
		||||
license = { text = "GPL-3.0" }
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@
 | 
			
		||||
    "enabled": true
 | 
			
		||||
  },
 | 
			
		||||
  "pre-commit": {
 | 
			
		||||
    "enabled": true
 | 
			
		||||
    "enabled": false
 | 
			
		||||
  },
 | 
			
		||||
  "packageRules": [
 | 
			
		||||
    {
 | 
			
		||||
 
 | 
			
		||||