v0.1.0
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
@@ -0,0 +1,42 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
.pycache/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project
|
||||
*.log
|
||||
.cache/
|
||||
@@ -0,0 +1,40 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2026 Cheviiot
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
DETAILED TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
The full text of the GNU General Public License version 3 is available at:
|
||||
<https://www.gnu.org/licenses/gpl-3.0.txt>
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,99 @@
|
||||
<p align="center">
|
||||
<img src="data/Vual.png" alt="Vual" width="96">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Vual</h1>
|
||||
|
||||
<p align="center">
|
||||
GTK4-приложение для запуска Cheat Engine в Steam-играх через Proton
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-GPL--3.0-green?style=flat-square" alt="GPL-3.0"></a>
|
||||
<img src="https://img.shields.io/badge/python-3.11+-blue?style=flat-square" alt="Python 3.11+">
|
||||
<img src="https://img.shields.io/badge/gtk-4-4a86cf?style=flat-square" alt="GTK 4">
|
||||
<img src="https://img.shields.io/badge/libadwaita-1-3584e4?style=flat-square" alt="Libadwaita 1">
|
||||
</p>
|
||||
|
||||
## Что это
|
||||
|
||||
Vual показывает библиотеку установленных Steam-игр в виде сетки с обложками и позволяет в один клик запустить игру, включить для неё [protonhax](https://github.com/jcnils/protonhax) и открыть Cheat Engine прямо внутри Proton-контекста запущенной игры.
|
||||
|
||||
Приложение самостоятельно скачивает и распаковывает Cheat Engine, управляет protonhax, настраивает тему Wine для всех Proton-префиксов и поддерживает русский язык — как собственного интерфейса, так и Cheat Engine.
|
||||
|
||||
## Возможности
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 🎮 **Библиотека** | Сетка игр с обложками из Steam CDN, поиск, сортировка по имени и статусу |
|
||||
| ▶️ **Запуск** | Запуск игры через Steam URI, автоматическое подключение CE к запущенной Proton-игре |
|
||||
| 🔧 **protonhax** | Переключатель launch options для каждой игры и массовое включение/отключение |
|
||||
| 📦 **Cheat Engine** | Скачивание с cheatengine.org, распаковка через Wine из Proton, тестовый запуск |
|
||||
| 🎨 **Тема Wine** | Тёмная и светлая тема для всех Proton-префиксов разом |
|
||||
| 🌐 **Локализация** | Язык приложения (EN/RU), язык CE, установка русской локализации CE |
|
||||
| ⚙️ **Настройки** | Путь к Steam, шаблон LaunchOptions, regex-исключения, размер плитки |
|
||||
|
||||
## Установка
|
||||
|
||||
### Из исходников
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Cheviiot/vual.git
|
||||
cd vual
|
||||
./vual
|
||||
```
|
||||
|
||||
### Stapler (ALT Linux)
|
||||
|
||||
```bash
|
||||
stplr repo add lumenara https://github.com/Cheviiot/Lumenara
|
||||
stplr refresh && stplr install vual
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>Зависимости</strong></summary>
|
||||
|
||||
| Дистрибутив | Команда |
|
||||
|---|---|
|
||||
| ALT Linux | `apt-get install python3-module-pygobject3 libgtk4 libadwaita` |
|
||||
| Fedora | `dnf install python3-gobject gtk4 libadwaita` |
|
||||
| Arch | `pacman -S python-gobject gtk4 libadwaita` |
|
||||
| Debian / Ubuntu | `apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1` |
|
||||
|
||||
Также необходим **Proton** (установленный через Steam) — для распаковки CE и работы protonhax.
|
||||
|
||||
</details>
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
1. Запустите `./vual`
|
||||
2. Откройте настройки (☰ → Настройки)
|
||||
3. На вкладке **Cheat Engine** скачайте и распакуйте CE
|
||||
4. Установите **protonhax** если он ещё не установлен
|
||||
5. Включите переключатель у нужной игры — Vual пропишет launch options
|
||||
6. Нажмите ▶ у игры — после запуска CE откроется автоматически
|
||||
|
||||
## Структура данных
|
||||
|
||||
```
|
||||
~/.config/vual/
|
||||
└── config.json # Настройки приложения
|
||||
|
||||
~/.local/share/vual/
|
||||
├── cheatengine/ # Установленный Cheat Engine
|
||||
├── bin/protonhax # Управляемый скрипт protonhax
|
||||
└── wine_prefix/ # Префикс Wine для тестового запуска CE
|
||||
|
||||
~/.cache/vual/
|
||||
└── covers/ # Кэш обложек Steam
|
||||
```
|
||||
|
||||
## Участие в разработке
|
||||
|
||||
> **Это личный проект.** Vual создаётся одним человеком для собственного использования при помощи ИИ.
|
||||
|
||||
Код полностью открыт. Если хотите помочь — Pull Request и Issue приветствуются: исправление ошибок, новые функции, переводы, плагины.
|
||||
|
||||
## Лицензия
|
||||
|
||||
[GPL-3.0-or-later](LICENSE)
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 911 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1,16 @@
|
||||
[Desktop Entry]
|
||||
Name=Vual
|
||||
Name[ru]=Vual
|
||||
GenericName=Cheat Engine Manager
|
||||
GenericName[ru]=Менеджер Cheat Engine
|
||||
Comment=Launch Cheat Engine for Steam games via Proton
|
||||
Comment[ru]=Запуск Cheat Engine для Steam-игр через Proton
|
||||
Exec=vual
|
||||
Icon=io.github.vual
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Game;Utility;
|
||||
Keywords=cheat;engine;steam;proton;wine;games;trainer;
|
||||
Keywords[ru]=читы;движок;steam;proton;wine;игры;трейнер;
|
||||
StartupNotify=true
|
||||
StartupWMClass=vual
|
||||
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>io.github.vual</id>
|
||||
<name>Vual</name>
|
||||
<summary>Launch Cheat Engine for Steam games via Proton</summary>
|
||||
<summary xml:lang="ru">Запуск Cheat Engine для Steam-игр через Proton</summary>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>GPL-3.0-or-later</project_license>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
Vual is a GTK4/libadwaita application that lets you browse your Steam
|
||||
library, toggle protonhax launch options per game, and run Cheat Engine
|
||||
inside the Proton context of a running game — all from a single window.
|
||||
</p>
|
||||
<p xml:lang="ru">
|
||||
Vual — приложение на GTK4/libadwaita, которое показывает библиотеку Steam,
|
||||
позволяет включать protonhax для каждой игры и запускать Cheat Engine
|
||||
внутри Proton-контекста запущенной игры — из одного окна.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Game grid with covers from Steam CDN, search, and sorting</li>
|
||||
<li>Per-game and batch protonhax launch option management</li>
|
||||
<li>One-click game launch with automatic Cheat Engine attach</li>
|
||||
<li>Download and extract Cheat Engine via Proton Wine</li>
|
||||
<li>Dark and light Wine theme for all Proton prefixes</li>
|
||||
<li>Application and Cheat Engine language switching (EN/RU)</li>
|
||||
<li xml:lang="ru">Сетка игр с обложками из Steam CDN, поиск и сортировка</li>
|
||||
<li xml:lang="ru">Управление launch options protonhax по играм и массово</li>
|
||||
<li xml:lang="ru">Запуск игры с автоматическим подключением Cheat Engine</li>
|
||||
<li xml:lang="ru">Скачивание и распаковка Cheat Engine через Wine из Proton</li>
|
||||
<li xml:lang="ru">Тёмная и светлая тема Wine для всех Proton-префиксов</li>
|
||||
<li xml:lang="ru">Переключение языка приложения и Cheat Engine (EN/RU)</li>
|
||||
</ul>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">io.github.vual.desktop</launchable>
|
||||
|
||||
<url type="homepage">https://github.com/Cheviiot/vual</url>
|
||||
<url type="bugtracker">https://github.com/Cheviiot/vual/issues</url>
|
||||
<url type="vcs-browser">https://github.com/Cheviiot/vual</url>
|
||||
|
||||
<provides>
|
||||
<binary>vual</binary>
|
||||
</provides>
|
||||
|
||||
<requires>
|
||||
<display_length compare="ge">360</display_length>
|
||||
</requires>
|
||||
|
||||
<recommends>
|
||||
<control>pointing</control>
|
||||
<control>keyboard</control>
|
||||
</recommends>
|
||||
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#62a0ea</color>
|
||||
<color type="primary" scheme_preference="dark">#1a5fb4</color>
|
||||
</branding>
|
||||
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Steam game library</caption>
|
||||
<caption xml:lang="ru">Библиотека игр Steam</caption>
|
||||
<image>https://raw.githubusercontent.com/Cheviiot/vual/main/data/screenshots/main.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
|
||||
<releases>
|
||||
<release version="0.1.0" date="2026-03-17">
|
||||
<description>
|
||||
<p>First release.</p>
|
||||
<p xml:lang="ru">Первый выпуск.</p>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<developer id="io.github.cheviiot">
|
||||
<name>Cheviiot</name>
|
||||
</developer>
|
||||
</component>
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile .po files to .mo files for runtime use."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PO_DIR = Path(__file__).parent
|
||||
LOCALE_DIR = PO_DIR.parent / "src" / "vual" / "locale"
|
||||
|
||||
|
||||
def compile_po():
|
||||
"""Compile all .po files to .mo files."""
|
||||
for po_file in PO_DIR.glob("*.po"):
|
||||
lang = po_file.stem
|
||||
mo_dir = LOCALE_DIR / lang / "LC_MESSAGES"
|
||||
mo_dir.mkdir(parents=True, exist_ok=True)
|
||||
mo_file = mo_dir / "vual.mo"
|
||||
|
||||
print(f"Compiling {po_file.name} -> {mo_file.relative_to(PO_DIR.parent)}")
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["msgfmt", "-o", str(mo_file), str(po_file)],
|
||||
check=True,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
print("Error: msgfmt not found. Install gettext:")
|
||||
print(" sudo apt install gettext")
|
||||
sys.exit(1)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error compiling {po_file.name}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
print("Done!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
compile_po()
|
||||
@@ -0,0 +1,457 @@
|
||||
# English translation for Vual
|
||||
# Copyright (C) 2026 Vual Authors
|
||||
# This file is distributed under the same license as the Vual package.
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: vual 0.1.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-19 00:00+0000\n"
|
||||
"PO-Revision-Date: 2026-03-19 00:00+0000\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
# Window titles
|
||||
msgid "Preferences"
|
||||
msgstr "Preferences"
|
||||
|
||||
msgid "About Vual"
|
||||
msgstr "About Vual"
|
||||
|
||||
# Appearance page
|
||||
msgid "Appearance"
|
||||
msgstr "Appearance"
|
||||
|
||||
msgid "Theme"
|
||||
msgstr "Theme"
|
||||
|
||||
msgid "Color scheme"
|
||||
msgstr "Color scheme"
|
||||
|
||||
msgid "Light, dark, or system"
|
||||
msgstr "Light, dark, or system"
|
||||
|
||||
msgid "System"
|
||||
msgstr "System"
|
||||
|
||||
msgid "Light"
|
||||
msgstr "Light"
|
||||
|
||||
msgid "Dark"
|
||||
msgstr "Dark"
|
||||
|
||||
msgid "Language"
|
||||
msgstr "Language"
|
||||
|
||||
msgid "Application interface language"
|
||||
msgstr "Application interface language"
|
||||
|
||||
msgid "Restart app to apply language"
|
||||
msgstr "Restart app to apply language"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr "Grid"
|
||||
|
||||
msgid "Game display settings"
|
||||
msgstr "Game display settings"
|
||||
|
||||
msgid "Tile size"
|
||||
msgstr "Tile size"
|
||||
|
||||
msgid "Cover size in library"
|
||||
msgstr "Cover size in library"
|
||||
|
||||
msgid "Small (120px)"
|
||||
msgstr "Small (120px)"
|
||||
|
||||
msgid "Medium (150px)"
|
||||
msgstr "Medium (150px)"
|
||||
|
||||
msgid "Large (180px)"
|
||||
msgstr "Large (180px)"
|
||||
|
||||
msgid "Sort"
|
||||
msgstr "Sort"
|
||||
|
||||
msgid "Default game order"
|
||||
msgstr "Default game order"
|
||||
|
||||
msgid "By name"
|
||||
msgstr "By name"
|
||||
|
||||
msgid "By status"
|
||||
msgstr "By status"
|
||||
|
||||
# Wine theme
|
||||
msgid "Wine Theme"
|
||||
msgstr "Wine Theme"
|
||||
|
||||
msgid "Color scheme for Cheat Engine and other Wine apps"
|
||||
msgstr "Color scheme for Cheat Engine and other Wine apps"
|
||||
|
||||
msgid "Applied to all Proton prefixes"
|
||||
msgstr "Applied to all Proton prefixes"
|
||||
|
||||
msgid "Reapply theme"
|
||||
msgstr "Reapply theme"
|
||||
|
||||
msgid "Theme applied to %d prefixes"
|
||||
msgstr "Theme applied to %d prefixes"
|
||||
|
||||
msgid "Error applying to %d prefixes"
|
||||
msgstr "Error applying to %d prefixes"
|
||||
|
||||
# Steam page
|
||||
msgid "Steam"
|
||||
msgstr "Steam"
|
||||
|
||||
msgid "Paths"
|
||||
msgstr "Paths"
|
||||
|
||||
msgid "Steam directory"
|
||||
msgstr "Steam directory"
|
||||
|
||||
msgid "Choose folder"
|
||||
msgstr "Choose folder"
|
||||
|
||||
msgid "Launch options"
|
||||
msgstr "Launch options"
|
||||
|
||||
msgid "LaunchOptions template for protonhax"
|
||||
msgstr "LaunchOptions template for protonhax"
|
||||
|
||||
msgid "Template"
|
||||
msgstr "Template"
|
||||
|
||||
msgid "Original launch command is substituted"
|
||||
msgstr "Original launch command is substituted"
|
||||
|
||||
msgid "Exclusions"
|
||||
msgstr "Exclusions"
|
||||
|
||||
msgid "Regex patterns to hide apps"
|
||||
msgstr "Regex patterns to hide apps"
|
||||
|
||||
msgid "Add pattern"
|
||||
msgstr "Add pattern"
|
||||
|
||||
msgid "Remove"
|
||||
msgstr "Remove"
|
||||
|
||||
# CE page
|
||||
msgid "Cheat Engine"
|
||||
msgstr "Cheat Engine"
|
||||
|
||||
msgid "Installation"
|
||||
msgstr "Installation"
|
||||
|
||||
msgid "Path to executable"
|
||||
msgstr "Path to executable"
|
||||
|
||||
msgid "Download"
|
||||
msgstr "Download"
|
||||
|
||||
msgid "Download Cheat Engine"
|
||||
msgstr "Download Cheat Engine"
|
||||
|
||||
msgid "Latest GitHub release"
|
||||
msgstr "Latest GitHub release"
|
||||
|
||||
msgid "Check for updates"
|
||||
msgstr "Check for updates"
|
||||
|
||||
msgid "Checking..."
|
||||
msgstr "Checking..."
|
||||
|
||||
msgid "CE is up to date"
|
||||
msgstr "CE is up to date"
|
||||
|
||||
msgid "Update available: %s"
|
||||
msgstr "Update available: %s"
|
||||
|
||||
msgid "Failed to check"
|
||||
msgstr "Failed to check"
|
||||
|
||||
msgid "Downloading..."
|
||||
msgstr "Downloading..."
|
||||
|
||||
msgid "CE downloaded successfully"
|
||||
msgstr "CE downloaded successfully"
|
||||
|
||||
msgid "Download failed"
|
||||
msgstr "Download failed"
|
||||
|
||||
msgid "Localization"
|
||||
msgstr "Localization"
|
||||
|
||||
msgid "Install Russian localization"
|
||||
msgstr "Install Russian localization"
|
||||
|
||||
msgid "Downloads from official Cheat Engine repository"
|
||||
msgstr "Downloads from official Cheat Engine repository"
|
||||
|
||||
msgid "Localization installed"
|
||||
msgstr "Localization installed"
|
||||
|
||||
msgid "Localization already installed"
|
||||
msgstr "Localization already installed"
|
||||
|
||||
msgid "CE interface language"
|
||||
msgstr "CE interface language"
|
||||
|
||||
msgid "Russian"
|
||||
msgstr "Russian"
|
||||
|
||||
# Main page
|
||||
msgid "Search..."
|
||||
msgstr "Search..."
|
||||
|
||||
msgid "Games not found"
|
||||
msgstr "Games not found"
|
||||
|
||||
msgid "Check Steam path in settings"
|
||||
msgstr "Check Steam path in settings"
|
||||
|
||||
msgid "Refresh cover"
|
||||
msgstr "Refresh cover"
|
||||
|
||||
msgid "Launch game"
|
||||
msgstr "Launch game"
|
||||
|
||||
msgid "Launch Cheat Engine"
|
||||
msgstr "Launch Cheat Engine"
|
||||
|
||||
msgid "Launching %s..."
|
||||
msgstr "Launching %s..."
|
||||
|
||||
msgid "CE not found: %s"
|
||||
msgstr "CE not found: %s"
|
||||
|
||||
msgid "CE launched for %s"
|
||||
msgstr "CE launched for %s"
|
||||
|
||||
msgid "Failed to launch CE for %s"
|
||||
msgstr "Failed to launch CE for %s"
|
||||
|
||||
msgid "Close Steam to change settings"
|
||||
msgstr "Close Steam to change settings"
|
||||
|
||||
msgid "localconfig.vdf not found"
|
||||
msgstr "localconfig.vdf not found"
|
||||
|
||||
msgid "LaunchOptions set for %s"
|
||||
msgstr "LaunchOptions set for %s"
|
||||
|
||||
msgid "LaunchOptions removed for %s"
|
||||
msgstr "LaunchOptions removed for %s"
|
||||
|
||||
msgid "Error changing settings for %s"
|
||||
msgstr "Error changing settings for %s"
|
||||
|
||||
msgid "Enable for all games?"
|
||||
msgstr "Enable for all games?"
|
||||
|
||||
msgid "Enable"
|
||||
msgstr "Enable"
|
||||
|
||||
msgid "Disable for all games?"
|
||||
msgstr "Disable for all games?"
|
||||
|
||||
msgid "LaunchOptions will be removed."
|
||||
msgstr "LaunchOptions will be removed."
|
||||
|
||||
msgid "Disable"
|
||||
msgstr "Disable"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "Cancel"
|
||||
|
||||
msgid "Restarting Steam to apply..."
|
||||
msgstr "Restarting Steam to apply..."
|
||||
|
||||
msgid "Steam did not close"
|
||||
msgstr "Steam did not close"
|
||||
|
||||
msgid "LaunchOptions set for %d games"
|
||||
msgstr "LaunchOptions set for %d games"
|
||||
|
||||
msgid "LaunchOptions removed for %d games"
|
||||
msgstr "LaunchOptions removed for %d games"
|
||||
|
||||
# Window
|
||||
msgid "games"
|
||||
msgstr "games"
|
||||
|
||||
msgid "Enable All"
|
||||
msgstr "Enable All"
|
||||
|
||||
msgid "Disable All"
|
||||
msgstr "Disable All"
|
||||
|
||||
msgid "Settings"
|
||||
msgstr "Settings"
|
||||
|
||||
msgid "About"
|
||||
msgstr "About"
|
||||
|
||||
# CE page additional
|
||||
msgid "All files"
|
||||
msgstr "All files"
|
||||
|
||||
msgid "Cheat Engine setup and download"
|
||||
msgstr "Cheat Engine setup and download"
|
||||
|
||||
msgid "Cheat Engine installed"
|
||||
msgstr "Cheat Engine installed"
|
||||
|
||||
msgid "Cheat Engine interface language"
|
||||
msgstr "Cheat Engine interface language"
|
||||
|
||||
msgid "Debug"
|
||||
msgstr "Debug"
|
||||
|
||||
msgid "Downloads latest version from GitHub"
|
||||
msgstr "Downloads latest version from GitHub"
|
||||
|
||||
msgid "Cheat Engine launched"
|
||||
msgstr "Cheat Engine launched"
|
||||
|
||||
msgid "Cheat Engine not found"
|
||||
msgstr "Cheat Engine not found"
|
||||
|
||||
msgid "Cheat Engine required"
|
||||
msgstr "Cheat Engine required"
|
||||
|
||||
msgid "Check"
|
||||
msgstr "Check"
|
||||
|
||||
msgid "Choose file"
|
||||
msgstr "Choose file"
|
||||
|
||||
msgid "Click Check"
|
||||
msgstr "Click Check"
|
||||
|
||||
msgid "%d games"
|
||||
msgstr "%d games"
|
||||
|
||||
msgid "Downloading: %s"
|
||||
msgstr "Downloading: %s"
|
||||
|
||||
msgid "Error: %s"
|
||||
msgstr "Error: %s"
|
||||
|
||||
msgid "Executable"
|
||||
msgstr "Executable"
|
||||
|
||||
msgid "Executable files (*.exe)"
|
||||
msgstr "Executable files (*.exe)"
|
||||
|
||||
msgid "✓ External: %s"
|
||||
msgstr "✓ External: %s"
|
||||
|
||||
msgid "Extracted — specify path manually"
|
||||
msgstr "Extracted — specify path manually"
|
||||
|
||||
msgid "Extracting..."
|
||||
msgstr "Extracting..."
|
||||
|
||||
msgid "Extraction failed"
|
||||
msgstr "Extraction failed"
|
||||
|
||||
msgid "Failed to get info"
|
||||
msgstr "Failed to get info"
|
||||
|
||||
msgid "Failed to install localization"
|
||||
msgstr "Failed to install localization"
|
||||
|
||||
msgid "Install"
|
||||
msgstr "Install"
|
||||
|
||||
msgid "✓ Installed"
|
||||
msgstr "✓ Installed"
|
||||
|
||||
msgid "Install or update"
|
||||
msgstr "Install or update"
|
||||
|
||||
msgid "Interface"
|
||||
msgstr "Interface"
|
||||
|
||||
msgid "Latest version"
|
||||
msgstr "Latest version"
|
||||
|
||||
msgid "Launch"
|
||||
msgstr "Launch"
|
||||
|
||||
msgid "Launch CE via Wine (without game binding)"
|
||||
msgstr "Launch CE via Wine (without game binding)"
|
||||
|
||||
msgid "Required for attaching to Proton games"
|
||||
msgstr "Required for attaching to Proton games"
|
||||
|
||||
msgid "Requires CE restart"
|
||||
msgstr "Requires CE restart"
|
||||
|
||||
msgid "LaunchOptions %s for %s"
|
||||
msgstr "LaunchOptions %s for %s"
|
||||
|
||||
msgid "MB"
|
||||
msgstr "MB"
|
||||
|
||||
msgid "✗ Not found"
|
||||
msgstr "✗ Not found"
|
||||
|
||||
msgid "✗ Not installed"
|
||||
msgstr "✗ Not installed"
|
||||
|
||||
msgid "Not installed"
|
||||
msgstr "Not installed"
|
||||
|
||||
msgid "protonhax installed"
|
||||
msgstr "protonhax installed"
|
||||
|
||||
msgid "Quit"
|
||||
msgstr "Quit"
|
||||
|
||||
msgid "Refresh"
|
||||
msgstr "Refresh"
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr "Reinstall"
|
||||
|
||||
msgid "removed"
|
||||
msgstr "removed"
|
||||
|
||||
msgid "Russian localization"
|
||||
msgstr "Russian localization"
|
||||
|
||||
msgid "Select CE executable"
|
||||
msgstr "Select CE executable"
|
||||
|
||||
msgid "Select Steam directory"
|
||||
msgstr "Select Steam directory"
|
||||
|
||||
msgid "set"
|
||||
msgstr "set"
|
||||
|
||||
msgid "Status"
|
||||
msgstr "Status"
|
||||
|
||||
msgid "Steam did not exit"
|
||||
msgstr "Steam did not exit"
|
||||
|
||||
msgid "Test launch"
|
||||
msgstr "Test launch"
|
||||
|
||||
msgid "⟳ Update available"
|
||||
msgstr "⟳ Update available"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Update"
|
||||
|
||||
msgid "Wine not found — install Proton"
|
||||
msgstr "Wine not found — install Proton"
|
||||
@@ -0,0 +1,460 @@
|
||||
# Russian translation for Vual
|
||||
# Copyright (C) 2026 Vual Authors
|
||||
# This file is distributed under the same license as the Vual package.
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: vual 0.1.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-19 00:00+0000\n"
|
||||
"PO-Revision-Date: 2026-03-19 00:00+0000\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Russian\n"
|
||||
"Language: ru\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||
|
||||
# Window titles
|
||||
msgid "Preferences"
|
||||
msgstr "Настройки"
|
||||
|
||||
msgid "About Vual"
|
||||
msgstr "О Vual"
|
||||
|
||||
# Appearance page
|
||||
msgid "Appearance"
|
||||
msgstr "Внешний вид"
|
||||
|
||||
msgid "Theme"
|
||||
msgstr "Тема"
|
||||
|
||||
msgid "Color scheme"
|
||||
msgstr "Цветовая схема"
|
||||
|
||||
msgid "Light, dark, or system"
|
||||
msgstr "Светлая, тёмная или системная"
|
||||
|
||||
msgid "System"
|
||||
msgstr "Системная"
|
||||
|
||||
msgid "Light"
|
||||
msgstr "Светлая"
|
||||
|
||||
msgid "Dark"
|
||||
msgstr "Тёмная"
|
||||
|
||||
msgid "Language"
|
||||
msgstr "Язык"
|
||||
|
||||
msgid "Application interface language"
|
||||
msgstr "Язык интерфейса приложения"
|
||||
|
||||
msgid "Restart app to apply language"
|
||||
msgstr "Перезапустите приложение для смены языка"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr "Сетка"
|
||||
|
||||
msgid "Game display settings"
|
||||
msgstr "Настройки отображения игр"
|
||||
|
||||
msgid "Tile size"
|
||||
msgstr "Размер плиток"
|
||||
|
||||
msgid "Cover size in library"
|
||||
msgstr "Размер обложек в библиотеке"
|
||||
|
||||
msgid "Small (120px)"
|
||||
msgstr "Маленькие (120px)"
|
||||
|
||||
msgid "Medium (150px)"
|
||||
msgstr "Средние (150px)"
|
||||
|
||||
msgid "Large (180px)"
|
||||
msgstr "Большие (180px)"
|
||||
|
||||
msgid "Sort"
|
||||
msgstr "Сортировка"
|
||||
|
||||
msgid "Default game order"
|
||||
msgstr "Порядок игр по умолчанию"
|
||||
|
||||
msgid "By name"
|
||||
msgstr "По названию"
|
||||
|
||||
msgid "By status"
|
||||
msgstr "По статусу"
|
||||
|
||||
# Wine theme
|
||||
msgid "Wine Theme"
|
||||
msgstr "Тема Wine"
|
||||
|
||||
msgid "Color scheme for Cheat Engine and other Wine apps"
|
||||
msgstr "Цветовая схема для Cheat Engine и других Wine-приложений"
|
||||
|
||||
msgid "Applied to all Proton prefixes"
|
||||
msgstr "Применяется ко всем Proton-префиксам"
|
||||
|
||||
msgid "Reapply theme"
|
||||
msgstr "Применить тему заново"
|
||||
|
||||
msgid "Theme applied to %d prefixes"
|
||||
msgstr "Тема применена к %d префиксам"
|
||||
|
||||
msgid "Error applying to %d prefixes"
|
||||
msgstr "Ошибка применения к %d префиксам"
|
||||
|
||||
# Steam page
|
||||
msgid "Steam"
|
||||
msgstr "Steam"
|
||||
|
||||
msgid "Paths"
|
||||
msgstr "Пути"
|
||||
|
||||
msgid "Steam directory"
|
||||
msgstr "Директория Steam"
|
||||
|
||||
msgid "Choose folder"
|
||||
msgstr "Выбрать папку"
|
||||
|
||||
msgid "Launch options"
|
||||
msgstr "Параметры запуска"
|
||||
|
||||
msgid "LaunchOptions template for protonhax"
|
||||
msgstr "Шаблон LaunchOptions для protonhax"
|
||||
|
||||
msgid "Template"
|
||||
msgstr "Шаблон"
|
||||
|
||||
msgid "Original launch command is substituted"
|
||||
msgstr "Подставляется оригинальная команда запуска игры"
|
||||
|
||||
msgid "Exclusions"
|
||||
msgstr "Исключения"
|
||||
|
||||
msgid "Regex patterns to hide apps"
|
||||
msgstr "Regex-паттерны для скрытия приложений"
|
||||
|
||||
msgid "Add pattern"
|
||||
msgstr "Добавить паттерн"
|
||||
|
||||
msgid "Remove"
|
||||
msgstr "Удалить"
|
||||
|
||||
# CE page
|
||||
msgid "Cheat Engine"
|
||||
msgstr "Cheat Engine"
|
||||
|
||||
msgid "Installation"
|
||||
msgstr "Установка"
|
||||
|
||||
msgid "Path to executable"
|
||||
msgstr "Путь к исполняемому файлу"
|
||||
|
||||
msgid "Download"
|
||||
msgstr "Скачать"
|
||||
|
||||
msgid "Download Cheat Engine"
|
||||
msgstr "Скачать Cheat Engine"
|
||||
|
||||
msgid "Latest GitHub release"
|
||||
msgstr "Последний релиз с GitHub"
|
||||
|
||||
msgid "Check for updates"
|
||||
msgstr "Проверить обновления"
|
||||
|
||||
msgid "Checking..."
|
||||
msgstr "Проверка…"
|
||||
|
||||
msgid "CE is up to date"
|
||||
msgstr "CE актуален"
|
||||
|
||||
msgid "Update available: %s"
|
||||
msgstr "Доступно обновление: %s"
|
||||
|
||||
msgid "Failed to check"
|
||||
msgstr "Не удалось проверить"
|
||||
|
||||
msgid "Downloading..."
|
||||
msgstr "Загрузка…"
|
||||
|
||||
msgid "CE downloaded successfully"
|
||||
msgstr "CE успешно загружен"
|
||||
|
||||
msgid "Download failed"
|
||||
msgstr "Ошибка загрузки"
|
||||
|
||||
msgid "Localization"
|
||||
msgstr "Локализация"
|
||||
|
||||
msgid "Install Russian localization"
|
||||
msgstr "Установить русскую локализацию"
|
||||
|
||||
msgid "Downloads from official Cheat Engine repository"
|
||||
msgstr "Загружает с официального репозитория Cheat Engine"
|
||||
|
||||
msgid "Localization installed"
|
||||
msgstr "Локализация установлена"
|
||||
|
||||
msgid "Localization already installed"
|
||||
msgstr "Локализация уже установлена"
|
||||
|
||||
msgid "CE interface language"
|
||||
msgstr "Язык интерфейса CE"
|
||||
|
||||
msgid "Russian"
|
||||
msgstr "Русский"
|
||||
|
||||
# Main page
|
||||
msgid "Search..."
|
||||
msgstr "Поиск…"
|
||||
|
||||
msgid "Games not found"
|
||||
msgstr "Игры не найдены"
|
||||
|
||||
msgid "Check Steam path in settings"
|
||||
msgstr "Проверьте путь к Steam в настройках"
|
||||
|
||||
msgid "Refresh cover"
|
||||
msgstr "Обновить обложку"
|
||||
|
||||
msgid "Launch game"
|
||||
msgstr "Запустить игру"
|
||||
|
||||
msgid "Launch Cheat Engine"
|
||||
msgstr "Запустить Cheat Engine"
|
||||
|
||||
msgid "Launching %s..."
|
||||
msgstr "Запуск %s…"
|
||||
|
||||
msgid "CE not found: %s"
|
||||
msgstr "CE не найден: %s"
|
||||
|
||||
msgid "CE launched for %s"
|
||||
msgstr "CE запущен для %s"
|
||||
|
||||
msgid "Failed to launch CE for %s"
|
||||
msgstr "Не удалось запустить CE для %s"
|
||||
|
||||
msgid "Close Steam to change settings"
|
||||
msgstr "Закройте Steam для изменения настроек"
|
||||
|
||||
msgid "localconfig.vdf not found"
|
||||
msgstr "localconfig.vdf не найден"
|
||||
|
||||
msgid "LaunchOptions set for %s"
|
||||
msgstr "LaunchOptions установлены для %s"
|
||||
|
||||
msgid "LaunchOptions removed for %s"
|
||||
msgstr "LaunchOptions удалены для %s"
|
||||
|
||||
msgid "Error changing settings for %s"
|
||||
msgstr "Ошибка при изменении настроек для %s"
|
||||
|
||||
msgid "Enable for all games?"
|
||||
msgstr "Включить для всех игр?"
|
||||
|
||||
msgid "Enable"
|
||||
msgstr "Включить"
|
||||
|
||||
msgid "Disable for all games?"
|
||||
msgstr "Отключить для всех игр?"
|
||||
|
||||
msgid "LaunchOptions will be removed."
|
||||
msgstr "LaunchOptions будут удалены."
|
||||
|
||||
msgid "Disable"
|
||||
msgstr "Отключить"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "Отмена"
|
||||
|
||||
msgid "Restarting Steam to apply..."
|
||||
msgstr "Перезапуск Steam для применения…"
|
||||
|
||||
msgid "Steam did not close"
|
||||
msgstr "Steam не завершился"
|
||||
|
||||
msgid "LaunchOptions set for %d games"
|
||||
msgstr "LaunchOptions установлены для %d игр"
|
||||
|
||||
msgid "LaunchOptions removed for %d games"
|
||||
msgstr "LaunchOptions удалены для %d игр"
|
||||
|
||||
# Window
|
||||
msgid "games"
|
||||
msgstr "игр"
|
||||
|
||||
msgid "Enable All"
|
||||
msgstr "Включить всё"
|
||||
|
||||
msgid "Disable All"
|
||||
msgstr "Отключить всё"
|
||||
|
||||
msgid "Settings"
|
||||
msgstr "Настройки"
|
||||
|
||||
msgid "About"
|
||||
msgstr "О программе"
|
||||
|
||||
# CE page additional
|
||||
msgid "Actions"
|
||||
msgstr "Действия"
|
||||
|
||||
msgid "All files"
|
||||
msgstr "Все файлы"
|
||||
|
||||
msgid "Cheat Engine setup and download"
|
||||
msgstr "Настройка и скачивание Cheat Engine"
|
||||
|
||||
msgid "Debug"
|
||||
msgstr "Отладка"
|
||||
|
||||
msgid "Cheat Engine installed"
|
||||
msgstr "Cheat Engine установлен"
|
||||
|
||||
msgid "Cheat Engine interface language"
|
||||
msgstr "Язык интерфейса Cheat Engine"
|
||||
|
||||
msgid "Downloads latest version from GitHub"
|
||||
msgstr "Скачивает последнюю версию с GitHub"
|
||||
|
||||
msgid "Interface"
|
||||
msgstr "Интерфейс"
|
||||
|
||||
msgid "Cheat Engine launched"
|
||||
msgstr "Cheat Engine запущен"
|
||||
|
||||
msgid "Cheat Engine not found"
|
||||
msgstr "Cheat Engine не найден"
|
||||
|
||||
msgid "Cheat Engine required"
|
||||
msgstr "Требуется Cheat Engine"
|
||||
|
||||
msgid "Check"
|
||||
msgstr "Проверить"
|
||||
|
||||
msgid "Choose file"
|
||||
msgstr "Выбрать файл"
|
||||
|
||||
msgid "Click Check"
|
||||
msgstr "Нажмите «Проверить»"
|
||||
|
||||
msgid "%d games"
|
||||
msgstr "%d игр"
|
||||
|
||||
msgid "Downloading: %s"
|
||||
msgstr "Скачивание: %s"
|
||||
|
||||
msgid "Error: %s"
|
||||
msgstr "Ошибка: %s"
|
||||
|
||||
msgid "Executable"
|
||||
msgstr "Исполняемый файл"
|
||||
|
||||
msgid "Executable files (*.exe)"
|
||||
msgstr "Исполняемые файлы (*.exe)"
|
||||
|
||||
msgid "✓ External: %s"
|
||||
msgstr "✓ Внешний: %s"
|
||||
|
||||
msgid "Extracted — specify path manually"
|
||||
msgstr "Распаковано — укажите путь вручную"
|
||||
|
||||
msgid "Extracting..."
|
||||
msgstr "Распаковка…"
|
||||
|
||||
msgid "Extraction failed"
|
||||
msgstr "Распаковка не удалась"
|
||||
|
||||
msgid "Failed to get info"
|
||||
msgstr "Не удалось получить информацию"
|
||||
|
||||
msgid "Failed to install localization"
|
||||
msgstr "Не удалось установить локализацию"
|
||||
|
||||
msgid "Install"
|
||||
msgstr "Установить"
|
||||
|
||||
msgid "✓ Installed"
|
||||
msgstr "✓ Установлен"
|
||||
|
||||
msgid "Install or update"
|
||||
msgstr "Установить или обновить"
|
||||
|
||||
msgid "Latest version"
|
||||
msgstr "Последняя версия"
|
||||
|
||||
msgid "Launch"
|
||||
msgstr "Запустить"
|
||||
|
||||
msgid "Launch CE via Wine (without game binding)"
|
||||
msgstr "Запустить CE через Wine (без привязки к игре)"
|
||||
|
||||
msgid "LaunchOptions %s for %s"
|
||||
msgstr "LaunchOptions %s для %s"
|
||||
|
||||
msgid "MB"
|
||||
msgstr "МБ"
|
||||
|
||||
msgid "✗ Not found"
|
||||
msgstr "✗ Не найден"
|
||||
|
||||
msgid "✗ Not installed"
|
||||
msgstr "✗ Не установлен"
|
||||
|
||||
msgid "Not installed"
|
||||
msgstr "Не установлена"
|
||||
|
||||
msgid "protonhax installed"
|
||||
msgstr "protonhax установлен"
|
||||
|
||||
msgid "Quit"
|
||||
msgstr "Выход"
|
||||
|
||||
msgid "Refresh"
|
||||
msgstr "Обновить"
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr "Переустановить"
|
||||
|
||||
msgid "removed"
|
||||
msgstr "удалены"
|
||||
|
||||
msgid "Russian localization"
|
||||
msgstr "Русская локализация"
|
||||
|
||||
msgid "Select CE executable"
|
||||
msgstr "Выберите исполняемый файл CE"
|
||||
|
||||
msgid "Select Steam directory"
|
||||
msgstr "Выберите директорию Steam"
|
||||
|
||||
msgid "set"
|
||||
msgstr "установлены"
|
||||
|
||||
msgid "Status"
|
||||
msgstr "Статус"
|
||||
|
||||
msgid "Required for attaching to Proton games"
|
||||
msgstr "Требуется для подключения к играм Proton"
|
||||
|
||||
msgid "Requires CE restart"
|
||||
msgstr "Требует перезапуска CE"
|
||||
|
||||
msgid "Steam did not exit"
|
||||
msgstr "Steam не завершился"
|
||||
|
||||
msgid "Test launch"
|
||||
msgstr "Тест запуска"
|
||||
|
||||
msgid "⟳ Update available"
|
||||
msgstr "⟳ Доступно обновление"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Обновить"
|
||||
|
||||
msgid "Wine not found — install Proton"
|
||||
msgstr "Wine не найден — установите Proton"
|
||||
@@ -0,0 +1,298 @@
|
||||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) 2026 Vual Authors
|
||||
# This file is distributed under the same license as the Vual package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, 2026.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: vual 0.1.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-19 00:00+0000\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"
|
||||
"Language: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
# Window titles
|
||||
msgid "Preferences"
|
||||
msgstr ""
|
||||
|
||||
msgid "About Vual"
|
||||
msgstr ""
|
||||
|
||||
# Appearance page
|
||||
msgid "Appearance"
|
||||
msgstr ""
|
||||
|
||||
msgid "Theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Color scheme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Light, dark, or system"
|
||||
msgstr ""
|
||||
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game display settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Tile size"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover size in library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Small (120px)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Medium (150px)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Large (180px)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sort"
|
||||
msgstr ""
|
||||
|
||||
msgid "Default game order"
|
||||
msgstr ""
|
||||
|
||||
msgid "By name"
|
||||
msgstr ""
|
||||
|
||||
msgid "By status"
|
||||
msgstr ""
|
||||
|
||||
# Wine theme
|
||||
msgid "Wine Theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Color scheme for Cheat Engine and other Wine apps"
|
||||
msgstr ""
|
||||
|
||||
msgid "Color scheme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Applied to all Proton prefixes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Reapply theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Theme applied to %d prefixes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Error applying to %d prefixes"
|
||||
msgstr ""
|
||||
|
||||
# Steam page
|
||||
msgid "Steam"
|
||||
msgstr ""
|
||||
|
||||
msgid "Paths"
|
||||
msgstr ""
|
||||
|
||||
msgid "Steam directory"
|
||||
msgstr ""
|
||||
|
||||
msgid "Choose folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch options"
|
||||
msgstr ""
|
||||
|
||||
msgid "LaunchOptions template for protonhax"
|
||||
msgstr ""
|
||||
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
msgid "Original launch command is substituted"
|
||||
msgstr ""
|
||||
|
||||
msgid "Exclusions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Regex patterns to hide apps"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add pattern"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove"
|
||||
msgstr ""
|
||||
|
||||
# CE page
|
||||
msgid "Cheat Engine"
|
||||
msgstr ""
|
||||
|
||||
msgid "Installation"
|
||||
msgstr ""
|
||||
|
||||
msgid "Path to executable"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download Cheat Engine"
|
||||
msgstr ""
|
||||
|
||||
msgid "Latest GitHub release"
|
||||
msgstr ""
|
||||
|
||||
msgid "Check for updates"
|
||||
msgstr ""
|
||||
|
||||
msgid "Checking..."
|
||||
msgstr ""
|
||||
|
||||
msgid "CE is up to date"
|
||||
msgstr ""
|
||||
|
||||
msgid "Update available: %s"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to check"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading..."
|
||||
msgstr ""
|
||||
|
||||
msgid "CE downloaded successfully"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Localization"
|
||||
msgstr ""
|
||||
|
||||
msgid "Install Russian localization"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloads from official Cheat Engine repository"
|
||||
msgstr ""
|
||||
|
||||
msgid "Localization installed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Localization already installed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Language"
|
||||
msgstr ""
|
||||
|
||||
msgid "CE interface language"
|
||||
msgstr ""
|
||||
|
||||
msgid "Russian"
|
||||
msgstr ""
|
||||
|
||||
# Main page
|
||||
msgid "Search..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Games not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Check Steam path in settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Refresh cover"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch Cheat Engine"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launching %s..."
|
||||
msgstr ""
|
||||
|
||||
msgid "CE not found: %s"
|
||||
msgstr ""
|
||||
|
||||
msgid "CE launched for %s"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to launch CE for %s"
|
||||
msgstr ""
|
||||
|
||||
msgid "Close Steam to change settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "localconfig.vdf not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "LaunchOptions set for %s"
|
||||
msgstr ""
|
||||
|
||||
msgid "LaunchOptions removed for %s"
|
||||
msgstr ""
|
||||
|
||||
msgid "Error changing settings for %s"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable for all games?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable for all games?"
|
||||
msgstr ""
|
||||
|
||||
msgid "LaunchOptions will be removed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
msgid "Restarting Steam to apply..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Steam did not close"
|
||||
msgstr ""
|
||||
|
||||
msgid "LaunchOptions set for %d games"
|
||||
msgstr ""
|
||||
|
||||
msgid "LaunchOptions removed for %d games"
|
||||
msgstr ""
|
||||
|
||||
# Window
|
||||
msgid "games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable All"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable All"
|
||||
msgstr ""
|
||||
|
||||
msgid "Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "About"
|
||||
msgstr ""
|
||||
@@ -0,0 +1,39 @@
|
||||
[project]
|
||||
name = "vual"
|
||||
version = "0.1.0"
|
||||
description = "Launch Cheat Engine for Steam games via Proton"
|
||||
readme = "README.md"
|
||||
license = "GPL-3.0-or-later"
|
||||
requires-python = ">=3.11"
|
||||
authors = [{ name = "Cheviiot" }]
|
||||
keywords = ["cheatengine", "steam", "proton", "wine", "games", "gtk4", "libadwaita"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: X11 Applications :: GTK",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Games/Entertainment",
|
||||
]
|
||||
dependencies = ["requests>=2.31.0"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/Cheviiot/vual"
|
||||
Repository = "https://github.com/Cheviiot/vual"
|
||||
Issues = "https://github.com/Cheviiot/vual/issues"
|
||||
|
||||
[project.gui-scripts]
|
||||
vual = "vual.__main__:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/vual"]
|
||||
|
||||
[tool.uv]
|
||||
managed = true
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Vual — launch Cheat Engine for Steam games via Proton."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "0.1.0"
|
||||
APP_ID = "io.github.vual"
|
||||
APP_NAME = "Vual"
|
||||
|
||||
# Paths: try development first, then fall back to installed locations
|
||||
_PKG_DIR = Path(__file__).parent
|
||||
_DATA_DIR = _PKG_DIR.parent.parent / "data"
|
||||
|
||||
# Icon
|
||||
_DEV_ICON = _DATA_DIR / "Vual.png"
|
||||
ICON_PATH = _DEV_ICON if _DEV_ICON.exists() else None
|
||||
|
||||
# CSS (in package data)
|
||||
_PKG_CSS = _PKG_DIR / "data" / "style.css"
|
||||
CSS_PATH = _PKG_CSS if _PKG_CSS.exists() else None
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Entry point for the Vual GUI application."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _ensure_gi_available():
|
||||
"""Add system site-packages to path if gi is not importable (e.g., in venv)."""
|
||||
try:
|
||||
import gi # noqa: F401
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
candidates = [
|
||||
"/usr/lib64/python3/site-packages",
|
||||
"/usr/lib/python3/site-packages",
|
||||
"/usr/lib/python3/dist-packages",
|
||||
f"/usr/lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages",
|
||||
]
|
||||
for path in candidates:
|
||||
if os.path.isdir(os.path.join(path, "gi")):
|
||||
sys.path.insert(0, path)
|
||||
return
|
||||
|
||||
print(
|
||||
"Error: PyGObject (gi) not found.\n"
|
||||
"Install it with your package manager:\n"
|
||||
" ALT Linux: sudo apt-get install python3-module-pygobject3\n"
|
||||
" Fedora: sudo dnf install python3-gobject gtk4 libadwaita\n"
|
||||
" Ubuntu: sudo apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1\n"
|
||||
" Arch: sudo pacman -S python-gobject gtk4 libadwaita",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
_ensure_gi_available()
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
|
||||
from vual.app import VualApp
|
||||
|
||||
app = VualApp()
|
||||
app.run(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Adw.Application for Vual."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
|
||||
from gi.repository import Adw, Gdk, Gio, Gtk # noqa: E402
|
||||
|
||||
from vual import APP_ID, APP_NAME, CSS_PATH, ICON_PATH, __version__ # noqa: E402
|
||||
from vual.config import Config # noqa: E402
|
||||
from vual.i18n import init as init_i18n # noqa: E402
|
||||
from vual.window import VualWindow # noqa: E402
|
||||
|
||||
|
||||
class VualApp(Adw.Application):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.DEFAULT_FLAGS)
|
||||
self.config = Config.load()
|
||||
init_i18n(self.config.app_language) # Initialize translations with saved language
|
||||
|
||||
# ── Activate ─────────────────────────────────────────────────
|
||||
|
||||
def do_activate(self) -> None:
|
||||
# Apply color scheme
|
||||
style_manager = Adw.StyleManager.get_default()
|
||||
scheme_map = {
|
||||
"light": Adw.ColorScheme.FORCE_LIGHT,
|
||||
"dark": Adw.ColorScheme.FORCE_DARK,
|
||||
"system": Adw.ColorScheme.DEFAULT,
|
||||
}
|
||||
style_manager.set_color_scheme(scheme_map.get(self.config.color_scheme, Adw.ColorScheme.DEFAULT))
|
||||
|
||||
win = self.props.active_window
|
||||
if not win:
|
||||
win = VualWindow(application=self, config=self.config)
|
||||
win.set_default_size(self.config.window_width, self.config.window_height)
|
||||
win.present()
|
||||
|
||||
# ── Startup: register actions ────────────────────────────────
|
||||
|
||||
def do_startup(self) -> None:
|
||||
Adw.Application.do_startup(self)
|
||||
display = Gdk.Display.get_default()
|
||||
|
||||
# Load application CSS
|
||||
if CSS_PATH and display:
|
||||
css_provider = Gtk.CssProvider()
|
||||
css_provider.load_from_path(str(CSS_PATH))
|
||||
Gtk.StyleContext.add_provider_for_display(
|
||||
display,
|
||||
css_provider,
|
||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
)
|
||||
|
||||
# Add custom icon path to theme (for development)
|
||||
if ICON_PATH and display:
|
||||
Gtk.IconTheme.get_for_display(display).add_search_path(str(ICON_PATH.parent))
|
||||
|
||||
about_action = Gio.SimpleAction.new("about", None)
|
||||
about_action.connect("activate", self._on_about)
|
||||
self.add_action(about_action)
|
||||
|
||||
prefs_action = Gio.SimpleAction.new("preferences", None)
|
||||
prefs_action.connect("activate", self._on_preferences)
|
||||
self.add_action(prefs_action)
|
||||
|
||||
quit_action = Gio.SimpleAction.new("quit", None)
|
||||
quit_action.connect("activate", lambda *_: self.quit())
|
||||
self.add_action(quit_action)
|
||||
self.set_accels_for_action("app.quit", ["<primary>q"])
|
||||
|
||||
# ── About dialog ─────────────────────────────────────────────
|
||||
|
||||
def _on_about(self, _action: Gio.SimpleAction, _param: None) -> None:
|
||||
# Use dev icon name if in dev mode, otherwise use app_id (installed icon)
|
||||
icon_name = "Vual" if ICON_PATH else APP_ID
|
||||
about = Adw.AboutDialog(
|
||||
application_name=APP_NAME,
|
||||
application_icon=icon_name,
|
||||
version=__version__,
|
||||
developer_name="Cheviiot",
|
||||
website="https://github.com/Cheviiot/vual",
|
||||
issue_url="https://github.com/Cheviiot/vual/issues",
|
||||
developers=["Cheviiot"],
|
||||
copyright="© 2026 Cheviiot",
|
||||
license_type=Gtk.License.GPL_3_0,
|
||||
)
|
||||
about.present(self.props.active_window)
|
||||
|
||||
# ── Preferences ──────────────────────────────────────────────
|
||||
|
||||
def _on_preferences(self, _action: Gio.SimpleAction, _param: None) -> None:
|
||||
from vual.ui.preferences import PreferencesWindow
|
||||
|
||||
win = PreferencesWindow(config=self.config, transient_for=self.props.active_window)
|
||||
win.present()
|
||||
@@ -0,0 +1,463 @@
|
||||
"""Cheat Engine management: download, extraction, and version detection.
|
||||
|
||||
Provides functionality for:
|
||||
- Fetching latest CE release info from cheatengine.org
|
||||
- Downloading the installer with progress reporting
|
||||
- Extracting using Wine from Proton (silent install)
|
||||
- Detecting CE version from installed files
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import requests
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
# Official Cheat Engine download page
|
||||
CE_DOWNLOADS_URL = "https://cheatengine.org/downloads.php"
|
||||
|
||||
# Known Proton versions to search for Wine binary
|
||||
_PROTON_DIRS = [
|
||||
"Proton - Experimental",
|
||||
"Proton 9.0",
|
||||
"Proton 8.0",
|
||||
"Proton 7.0",
|
||||
]
|
||||
|
||||
# CE executable names in order of preference
|
||||
_CE_EXECUTABLES = [
|
||||
"cheatengine-x86_64.exe",
|
||||
"cheatengine-i386.exe",
|
||||
"Cheat Engine.exe",
|
||||
]
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Release Info
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def get_latest_release() -> dict[str, str | int | None] | None:
|
||||
"""Fetch latest Cheat Engine release info from official website.
|
||||
|
||||
Scrapes cheatengine.org/downloads.php for the Windows installer link.
|
||||
|
||||
Returns:
|
||||
Dict with keys: version, url, name, size (or None on error).
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(CE_DOWNLOADS_URL, timeout=15)
|
||||
resp.raise_for_status()
|
||||
text = resp.text
|
||||
|
||||
# Extract version (e.g. "Cheat Engine 7.6")
|
||||
ver_m = re.search(r"Cheat Engine\s+([\d.]+)", text)
|
||||
version = ver_m.group(1) if ver_m else "unknown"
|
||||
|
||||
# Find the first cloudfront .exe link (Windows installer)
|
||||
exe_m = re.search(r'href="(https://[^"]*cloudfront[^"]*\.exe)"', text, re.I)
|
||||
if not exe_m:
|
||||
return {"version": version, "url": None, "name": None, "size": 0}
|
||||
|
||||
url = exe_m.group(1)
|
||||
name = f"CheatEngine{version.replace('.', '')}.exe"
|
||||
|
||||
# Get file size via HEAD request
|
||||
size = 0
|
||||
try:
|
||||
head = requests.head(url, allow_redirects=True, timeout=10)
|
||||
size = int(head.headers.get("content-length", 0))
|
||||
except requests.RequestException:
|
||||
pass
|
||||
|
||||
return {"version": version, "url": url, "name": name, "size": size}
|
||||
except (requests.RequestException, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Download
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def download_file(
|
||||
url: str,
|
||||
dest: Path,
|
||||
progress_cb: Callable[[float], None] | None = None,
|
||||
) -> bool:
|
||||
"""Download a file with optional progress reporting.
|
||||
|
||||
Args:
|
||||
url: URL to download.
|
||||
dest: Destination path for the file.
|
||||
progress_cb: Optional callback receiving progress (0.0-1.0).
|
||||
|
||||
Returns:
|
||||
True on success, False on error.
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(url, stream=True, timeout=120)
|
||||
resp.raise_for_status()
|
||||
total = int(resp.headers.get("content-length", 0))
|
||||
downloaded = 0
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(dest, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=65536):
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if progress_cb and total > 0:
|
||||
progress_cb(downloaded / total)
|
||||
return True
|
||||
except requests.RequestException:
|
||||
return False
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Proton Wine Discovery
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def find_proton_wine(steam_path: str = "~/.local/share/Steam") -> Path | None:
|
||||
"""Find a Wine binary bundled with a Proton installation.
|
||||
|
||||
Searches common Proton versions first, then falls back to any
|
||||
Proton directory in steamapps/common.
|
||||
|
||||
Args:
|
||||
steam_path: Path to Steam installation (can use ~).
|
||||
|
||||
Returns:
|
||||
Path to Wine binary, or None if not found.
|
||||
"""
|
||||
steamapps = Path(steam_path).expanduser() / "steamapps" / "common"
|
||||
if not steamapps.is_dir():
|
||||
return None
|
||||
for name in _PROTON_DIRS:
|
||||
wine = steamapps / name / "files" / "bin" / "wine"
|
||||
if wine.is_file():
|
||||
return wine
|
||||
# Fallback: search any Proton directory
|
||||
for d in sorted(steamapps.iterdir(), reverse=True):
|
||||
if d.name.lower().startswith("proton"):
|
||||
wine = d / "files" / "bin" / "wine"
|
||||
if wine.is_file():
|
||||
return wine
|
||||
return None
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Extraction
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def can_extract(steam_path: str = "~/.local/share/Steam") -> str | None:
|
||||
"""Check if extraction is possible.
|
||||
|
||||
Args:
|
||||
steam_path: Path to Steam installation.
|
||||
|
||||
Returns:
|
||||
Name of extraction method ("wine") or None if unavailable.
|
||||
"""
|
||||
if find_proton_wine(steam_path):
|
||||
return "wine"
|
||||
return None
|
||||
|
||||
|
||||
def extract_installer(
|
||||
installer: Path,
|
||||
dest_dir: Path,
|
||||
steam_path: str = "~/.local/share/Steam",
|
||||
) -> bool:
|
||||
"""Extract Cheat Engine installer into destination directory.
|
||||
|
||||
Uses Wine from Proton to run the silent installer. The CE installer
|
||||
is wrapped by zbShield which doesn't exit cleanly in silent mode,
|
||||
so we poll for completion and kill the process.
|
||||
|
||||
Args:
|
||||
installer: Path to CE installer executable.
|
||||
dest_dir: Destination directory for extracted files.
|
||||
steam_path: Path to Steam installation.
|
||||
|
||||
Returns:
|
||||
True on success, False on error.
|
||||
"""
|
||||
wine = find_proton_wine(steam_path)
|
||||
if not wine:
|
||||
return False
|
||||
return _extract_via_wine(wine, installer, dest_dir)
|
||||
|
||||
|
||||
def _extract_via_wine(wine: Path, installer: Path, dest_dir: Path) -> bool:
|
||||
"""Run CE installer silently via Proton Wine and copy results."""
|
||||
cache_dir = Path.home() / ".cache" / "vual"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
wineserver = wine.parent / "wineserver"
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=cache_dir, prefix="ce_install_") as tmpdir:
|
||||
prefix = Path(tmpdir) / "prefix"
|
||||
prefix.mkdir()
|
||||
|
||||
env = dict(os.environ)
|
||||
env.update({
|
||||
"WINEPREFIX": str(prefix),
|
||||
"WINEDLLOVERRIDES": "mshtml=d",
|
||||
"WINEDEBUG": "-all",
|
||||
"PATH": str(wine.parent) + ":" + env.get("PATH", ""),
|
||||
})
|
||||
|
||||
# Initialize Wine prefix
|
||||
try:
|
||||
subprocess.run(
|
||||
[str(wine), "wineboot", "--init"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=120,
|
||||
env=env,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
# Launch installer as a background process — zbShield wrapper
|
||||
# never exits cleanly in silent mode, so we poll for results.
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
str(wine),
|
||||
str(installer),
|
||||
"/VERYSILENT",
|
||||
"/SUPPRESSMSGBOXES",
|
||||
"/NORESTART",
|
||||
"/SP-",
|
||||
"/NORUN",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=env,
|
||||
)
|
||||
|
||||
ce_src = prefix / "drive_c" / "Program Files" / "Cheat Engine"
|
||||
marker = ce_src / "cheatengine-x86_64.exe"
|
||||
completion = ce_src / "unins000.exe"
|
||||
|
||||
# Poll until both the 64-bit CE exe AND the uninstaller appear
|
||||
# and the total file count stabilises for 3 consecutive seconds.
|
||||
prev_count = 0
|
||||
stable = 0
|
||||
try:
|
||||
for _ in range(180): # max 3 minutes
|
||||
time.sleep(1)
|
||||
if marker.is_file() and completion.is_file():
|
||||
cur = sum(1 for _ in ce_src.rglob("*"))
|
||||
if cur == prev_count and cur > 50:
|
||||
stable += 1
|
||||
if stable >= 3:
|
||||
break
|
||||
else:
|
||||
stable = 0
|
||||
prev_count = cur
|
||||
else:
|
||||
# Timeout — use whatever we have if any
|
||||
if not ce_src.is_dir():
|
||||
return False
|
||||
finally:
|
||||
proc.kill()
|
||||
try:
|
||||
subprocess.run(
|
||||
[str(wineserver), "-k"],
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=10,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
time.sleep(1)
|
||||
|
||||
if not ce_src.is_dir():
|
||||
return False
|
||||
|
||||
# Move installed files to dest_dir
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
for item in ce_src.iterdir():
|
||||
target = dest_dir / item.name
|
||||
if target.exists():
|
||||
if target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
shutil.move(str(item), str(target))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Executable Detection
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def find_executable(search_dir: Path) -> Path | None:
|
||||
"""Find the main CE executable in a directory.
|
||||
|
||||
Searches for known CE executable names in order of preference.
|
||||
|
||||
Args:
|
||||
search_dir: Directory to search recursively.
|
||||
|
||||
Returns:
|
||||
Path to CE executable, or None if not found.
|
||||
"""
|
||||
# Try known names first
|
||||
for name in _CE_EXECUTABLES:
|
||||
for hit in search_dir.rglob(name):
|
||||
return hit
|
||||
# Fallback: any cheatengine*.exe
|
||||
for hit in search_dir.rglob("cheatengine*.exe"):
|
||||
return hit
|
||||
return None
|
||||
|
||||
|
||||
def detect_version(ce_path: Path) -> str | None:
|
||||
"""Detect CE version from file names or paths.
|
||||
|
||||
Args:
|
||||
ce_path: Path to CE installation.
|
||||
|
||||
Returns:
|
||||
Version string (e.g., "7.6") or None if not detected.
|
||||
"""
|
||||
# Check installer filename in cache (e.g. CheatEngine76.exe → 7.6)
|
||||
cache = Path.home() / ".cache" / "vual"
|
||||
if cache.is_dir():
|
||||
for f in cache.iterdir():
|
||||
if f.suffix == ".exe" and "cheatengine" in f.name.lower():
|
||||
m = re.search(r"(\d)(\d+)", f.stem)
|
||||
if m:
|
||||
return f"{m.group(1)}.{m.group(2)}"
|
||||
|
||||
# Check parent directory name
|
||||
for part in ce_path.parts:
|
||||
m = re.search(r"[Cc]heat.?[Ee]ngine\s*(\d+\.?\d*)", part)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Localization
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
|
||||
# Russian localization from official CE repo
|
||||
_LOCALIZATION_API = (
|
||||
"https://api.github.com/repos/cheat-engine/cheat-engine/contents/"
|
||||
"Cheat%20Engine/bin/languages/ru_RU"
|
||||
)
|
||||
_LOCALIZATION_RAW = (
|
||||
"https://raw.githubusercontent.com/cheat-engine/cheat-engine/master/"
|
||||
"Cheat%20Engine/bin/languages/ru_RU/"
|
||||
)
|
||||
_LOCALIZATION_DIR = "ru_RU"
|
||||
|
||||
|
||||
def get_languages_dir(ce_path: Path) -> Path | None:
|
||||
"""Get the languages directory for CE installation.
|
||||
|
||||
Args:
|
||||
ce_path: Path to CE executable.
|
||||
|
||||
Returns:
|
||||
Path to languages directory, or None if invalid.
|
||||
"""
|
||||
if ce_path.is_file():
|
||||
return ce_path.parent / "languages"
|
||||
return None
|
||||
|
||||
|
||||
def is_localization_installed(ce_path: Path) -> bool:
|
||||
"""Check if Russian localization is installed.
|
||||
|
||||
Args:
|
||||
ce_path: Path to CE executable.
|
||||
|
||||
Returns:
|
||||
True if ru_RU directory exists with .po files.
|
||||
"""
|
||||
lang_dir = get_languages_dir(ce_path)
|
||||
if not lang_dir:
|
||||
return False
|
||||
ru_dir = lang_dir / _LOCALIZATION_DIR
|
||||
if not ru_dir.is_dir():
|
||||
return False
|
||||
# Check for at least one .po file
|
||||
return any(ru_dir.glob("*.po"))
|
||||
|
||||
|
||||
def _get_localization_files() -> list[str]:
|
||||
"""Fetch list of files in ru_RU localization directory from GitHub API."""
|
||||
try:
|
||||
resp = requests.get(_LOCALIZATION_API, timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if isinstance(data, list):
|
||||
return [item["name"] for item in data if item.get("type") == "file"]
|
||||
except (requests.RequestException, ValueError, KeyError):
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def install_localization(
|
||||
ce_path: Path,
|
||||
progress_cb: Callable[[float], None] | None = None,
|
||||
) -> bool:
|
||||
"""Download and install Russian localization for Cheat Engine.
|
||||
|
||||
Downloads ru_RU localization files from official CE repository
|
||||
and places them in the languages/ru_RU directory.
|
||||
|
||||
Args:
|
||||
ce_path: Path to CE executable.
|
||||
progress_cb: Optional callback receiving progress (0.0-1.0).
|
||||
|
||||
Returns:
|
||||
True on success, False on error.
|
||||
"""
|
||||
lang_dir = get_languages_dir(ce_path)
|
||||
if not lang_dir:
|
||||
return False
|
||||
|
||||
ru_dir = lang_dir / _LOCALIZATION_DIR
|
||||
ru_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
files = _get_localization_files()
|
||||
if not files:
|
||||
return False
|
||||
|
||||
total = len(files)
|
||||
downloaded = 0
|
||||
|
||||
for filename in files:
|
||||
url = _LOCALIZATION_RAW + filename
|
||||
dest = ru_dir / filename
|
||||
try:
|
||||
resp = requests.get(url, timeout=30)
|
||||
resp.raise_for_status()
|
||||
dest.write_bytes(resp.content)
|
||||
downloaded += 1
|
||||
if progress_cb:
|
||||
progress_cb(downloaded / total)
|
||||
except requests.RequestException:
|
||||
# Continue with other files
|
||||
pass
|
||||
|
||||
# Success if at least the main .po file downloaded
|
||||
return (ru_dir / "cheatengine-x86_64.po").is_file()
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Configuration management for Vual.
|
||||
|
||||
Stores settings in ~/.config/vual/config.json
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
_DEFAULT_PROTONHAX = str(Path.home() / ".local" / "share" / "vual" / "bin" / "protonhax")
|
||||
_DEFAULT_TEMPLATE = f"{_DEFAULT_PROTONHAX} init %COMMAND%"
|
||||
_DEFAULT_EXCLUDED = ["^Proton", "^Steam Linux Runtime"]
|
||||
|
||||
# Config file location
|
||||
CONFIG_PATH = Path.home() / ".config" / "vual" / "config.json"
|
||||
|
||||
|
||||
# Tile size presets: small=120, medium=150, large=180
|
||||
TILE_SIZES = {"small": 120, "medium": 150, "large": 180}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Application configuration.
|
||||
|
||||
Attributes:
|
||||
ce_executable: Path to Cheat Engine executable.
|
||||
steam_path: Path to Steam installation directory.
|
||||
lookup_enabled: Whether to look up game info.
|
||||
launch_options_template: Template for Steam launch options.
|
||||
excluded_app_patterns: Regex patterns to exclude apps.
|
||||
window_width: Main window width.
|
||||
window_height: Main window height.
|
||||
color_scheme: Color scheme ("system", "light", "dark").
|
||||
tile_size: Tile size preset ("small", "medium", "large").
|
||||
sort_by: Sort order ("name", "status").
|
||||
ce_language: CE language ("system", "ru_RU").
|
||||
wine_theme: Wine color theme ("system", "dark", "light").
|
||||
app_language: Application UI language ("system", "en", "ru").
|
||||
"""
|
||||
|
||||
ce_executable: str = "~/.local/share/vual/cheatengine/cheatengine-x86_64.exe"
|
||||
steam_path: str = "~/.local/share/Steam"
|
||||
lookup_enabled: bool = True
|
||||
launch_options_template: str = field(default_factory=lambda: _DEFAULT_TEMPLATE)
|
||||
excluded_app_patterns: list[str] = field(default_factory=lambda: _DEFAULT_EXCLUDED.copy())
|
||||
window_width: int = 1000
|
||||
window_height: int = 700
|
||||
color_scheme: str = "system"
|
||||
tile_size: str = "medium"
|
||||
sort_by: str = "name"
|
||||
ce_language: str = "system"
|
||||
wine_theme: str = "system"
|
||||
app_language: str = "system"
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Load / Save
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> "Config":
|
||||
"""Load configuration from JSON file.
|
||||
|
||||
Returns:
|
||||
Config instance with loaded or default values.
|
||||
"""
|
||||
if CONFIG_PATH.exists():
|
||||
try:
|
||||
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
return cls(
|
||||
ce_executable=data.get("ce_executable", cls.ce_executable),
|
||||
steam_path=data.get("steam_path", cls.steam_path),
|
||||
lookup_enabled=data.get("lookup_enabled", cls.lookup_enabled),
|
||||
launch_options_template=data.get("launch_options_template", _DEFAULT_TEMPLATE),
|
||||
excluded_app_patterns=data.get("excluded_app_patterns", _DEFAULT_EXCLUDED.copy()),
|
||||
window_width=data.get("window_width", cls.window_width),
|
||||
window_height=data.get("window_height", cls.window_height),
|
||||
color_scheme=data.get("color_scheme", cls.color_scheme),
|
||||
tile_size=data.get("tile_size", cls.tile_size),
|
||||
sort_by=data.get("sort_by", cls.sort_by),
|
||||
ce_language=data.get("ce_language", cls.ce_language),
|
||||
wine_theme=data.get("wine_theme", cls.wine_theme),
|
||||
app_language=data.get("app_language", cls.app_language),
|
||||
)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return cls()
|
||||
|
||||
def save(self) -> Path:
|
||||
"""Save configuration to JSON file.
|
||||
|
||||
Returns:
|
||||
Path to the saved config file.
|
||||
"""
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"ce_executable": self.ce_executable,
|
||||
"steam_path": self.steam_path,
|
||||
"lookup_enabled": self.lookup_enabled,
|
||||
"launch_options_template": self.launch_options_template,
|
||||
"excluded_app_patterns": self.excluded_app_patterns,
|
||||
"window_width": self.window_width,
|
||||
"window_height": self.window_height,
|
||||
"color_scheme": self.color_scheme,
|
||||
"tile_size": self.tile_size,
|
||||
"sort_by": self.sort_by,
|
||||
"ce_language": self.ce_language,
|
||||
"wine_theme": self.wine_theme,
|
||||
"app_language": self.app_language,
|
||||
}
|
||||
CONFIG_PATH.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
return CONFIG_PATH
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Derived Paths
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
|
||||
@property
|
||||
def steamapps_path(self) -> Path:
|
||||
"""Path to Steam's steamapps directory."""
|
||||
return Path(self.steam_path).expanduser() / "steamapps"
|
||||
|
||||
@property
|
||||
def ce_executable_path(self) -> Path:
|
||||
"""Expanded path to CE executable."""
|
||||
return Path(self.ce_executable).expanduser()
|
||||
|
||||
@property
|
||||
def ce_exists(self) -> bool:
|
||||
"""Check if CE executable exists."""
|
||||
return self.ce_executable_path.is_file()
|
||||
@@ -0,0 +1,195 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Vual — Unified Size System
|
||||
|
||||
Base unit: 4px
|
||||
All sizes are multiples of 4px for consistency
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
HEADER BAR
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Header buttons: 32x32 (8 units) */
|
||||
.header-btn {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.header-btn:hover {
|
||||
background: alpha(@accent_bg_color, 0.12);
|
||||
}
|
||||
|
||||
.header-btn:active {
|
||||
background: alpha(@accent_bg_color, 0.2);
|
||||
}
|
||||
|
||||
/* Games counter */
|
||||
.games-counter {
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
opacity: 0.6;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Search entry */
|
||||
searchentry {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
GRID LAYOUT
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.vual-grid {
|
||||
/* Padding handled in Python via set_margin_* */
|
||||
}
|
||||
|
||||
flowboxchild {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
flowboxchild:focus,
|
||||
flowboxchild:hover,
|
||||
flowboxchild:selected {
|
||||
background: transparent;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
TILE
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Tile container: 12px radius */
|
||||
.vual-tile {
|
||||
border-radius: 12px;
|
||||
background: @card_bg_color;
|
||||
box-shadow: 0 1px 2px alpha(black, 0.08);
|
||||
}
|
||||
|
||||
.vual-tile:hover {
|
||||
box-shadow: 0 2px 8px alpha(black, 0.15);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
TILE INFO OVERLAY (bottom)
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Info panel: gradient background */
|
||||
.vual-tile-info {
|
||||
padding: 24px 8px 8px 8px;
|
||||
background: linear-gradient(to bottom,
|
||||
transparent 0%,
|
||||
alpha(black, 0.6) 50%,
|
||||
alpha(black, 0.8) 100%);
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
|
||||
.vual-tile-info label {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.vual-tile-info .heading {
|
||||
font-weight: 600;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
TILE BUTTONS (play, CE)
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Tile buttons: 28x28 (7 units) */
|
||||
.tile-btn,
|
||||
.vual-tile-info button {
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: white;
|
||||
background: alpha(white, 0.12);
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.tile-btn:hover,
|
||||
.vual-tile-info button:hover {
|
||||
background: alpha(white, 0.24);
|
||||
}
|
||||
|
||||
.tile-btn:active,
|
||||
.vual-tile-info button:active {
|
||||
background: alpha(white, 0.32);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
TILE SWITCH
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.vual-tile-info switch {
|
||||
background: alpha(white, 0.15);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.vual-tile-info switch:checked {
|
||||
background: @accent_bg_color;
|
||||
}
|
||||
|
||||
.vual-tile-info switch slider {
|
||||
background: white;
|
||||
min-width: 16px;
|
||||
min-height: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
RELOAD BUTTON (top-left corner)
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Reload: 24x24 (6 units) */
|
||||
.vual-reload-btn {
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border-radius: 6px;
|
||||
background: alpha(black, 0.5);
|
||||
color: white;
|
||||
opacity: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.vual-tile:hover .vual-reload-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vual-reload-btn:hover {
|
||||
background: @accent_bg_color;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
RUNNING BADGE (top-right corner)
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Badge: 20px total (14px icon + 3px padding each side) */
|
||||
.vual-badge {
|
||||
background: @success_bg_color;
|
||||
border-radius: 50%;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
SKELETON (loading placeholder)
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.skeleton {
|
||||
background: alpha(@card_shade_color, 0.2);
|
||||
border-radius: 12px;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Internationalization support for Vual.
|
||||
|
||||
Provides gettext-based translation functions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gettext
|
||||
import locale
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Application domain
|
||||
DOMAIN = "vual"
|
||||
|
||||
# Locale directories to search (in order)
|
||||
_PKG_DIR = Path(__file__).parent
|
||||
_LOCALE_DIRS = [
|
||||
_PKG_DIR / "locale", # In-package (installed/compiled)
|
||||
Path("/usr/share/locale"), # System-wide
|
||||
Path("/usr/local/share/locale"), # Local install
|
||||
]
|
||||
|
||||
# Global translator
|
||||
_translator: gettext.GNUTranslations | gettext.NullTranslations | None = None
|
||||
_current_lang: str = "system"
|
||||
|
||||
|
||||
def _find_locale_dir() -> Path | None:
|
||||
"""Find the first existing locale directory."""
|
||||
for d in _LOCALE_DIRS:
|
||||
if d.is_dir():
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def _get_system_lang() -> str:
|
||||
"""Get language code from system locale."""
|
||||
lang = os.environ.get("LANGUAGE") or os.environ.get("LANG", "en_US.UTF-8")
|
||||
return lang.split(".")[0].split("_")[0] # e.g., "ru" from "ru_RU.UTF-8"
|
||||
|
||||
|
||||
def init(lang: str = "system") -> None:
|
||||
"""Initialize translations.
|
||||
|
||||
Args:
|
||||
lang: Language code ("system", "en", "ru").
|
||||
"system" uses system locale.
|
||||
"""
|
||||
global _translator, _current_lang
|
||||
_current_lang = lang
|
||||
|
||||
# Get system locale
|
||||
try:
|
||||
locale.setlocale(locale.LC_ALL, "")
|
||||
except locale.Error:
|
||||
pass
|
||||
|
||||
# Determine language to use
|
||||
if lang == "system":
|
||||
lang_code = _get_system_lang()
|
||||
else:
|
||||
lang_code = lang
|
||||
|
||||
locale_dir = _find_locale_dir()
|
||||
|
||||
if locale_dir:
|
||||
try:
|
||||
_translator = gettext.translation(
|
||||
DOMAIN,
|
||||
localedir=str(locale_dir),
|
||||
languages=[lang_code],
|
||||
fallback=True,
|
||||
)
|
||||
except OSError:
|
||||
_translator = gettext.NullTranslations()
|
||||
else:
|
||||
_translator = gettext.NullTranslations()
|
||||
|
||||
|
||||
def set_language(lang: str) -> None:
|
||||
"""Change application language.
|
||||
|
||||
Args:
|
||||
lang: Language code ("system", "en", "ru").
|
||||
"""
|
||||
init(lang)
|
||||
|
||||
|
||||
def get_current_language() -> str:
|
||||
"""Get current language setting."""
|
||||
return _current_lang
|
||||
|
||||
|
||||
def _(message: str) -> str:
|
||||
"""Translate a message string."""
|
||||
global _translator
|
||||
if _translator is None:
|
||||
init()
|
||||
return _translator.gettext(message) if _translator else message
|
||||
|
||||
|
||||
def ngettext(singular: str, plural: str, n: int) -> str:
|
||||
"""Translate a plural message."""
|
||||
global _translator
|
||||
if _translator is None:
|
||||
init()
|
||||
return _translator.ngettext(singular, plural, n) if _translator else (singular if n == 1 else plural)
|
||||
|
||||
|
||||
# Initialize on import
|
||||
init()
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Integrated protonhax — list & run Proton games, manage init script."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
# ── Managed paths ────────────────────────────────────────────────
|
||||
|
||||
MANAGED_DIR = Path.home() / ".local" / "share" / "vual" / "bin"
|
||||
MANAGED_PATH = MANAGED_DIR / "protonhax"
|
||||
|
||||
|
||||
def _runtime_dir() -> Path:
|
||||
xdg = os.environ.get("XDG_RUNTIME_DIR")
|
||||
if xdg:
|
||||
return Path(xdg) / "protonhax"
|
||||
return Path(f"/run/user/{os.getuid()}") / "protonhax"
|
||||
|
||||
|
||||
# ── List running games ──────────────────────────────────────────
|
||||
|
||||
|
||||
def list_running() -> list[str]:
|
||||
"""Return app IDs of currently running Proton games."""
|
||||
phd = _runtime_dir()
|
||||
if not phd.is_dir():
|
||||
return []
|
||||
return sorted(d.name for d in phd.iterdir() if d.is_dir() and d.name.isdigit())
|
||||
|
||||
|
||||
# ── Game context ────────────────────────────────────────────────
|
||||
|
||||
_ENV_RE = re.compile(r'^declare\s+-x\s+(\w+)="(.*)"$')
|
||||
|
||||
|
||||
def _parse_bash_env(text: str) -> dict[str, str]:
|
||||
env: dict[str, str] = {}
|
||||
for line in text.splitlines():
|
||||
m = _ENV_RE.match(line)
|
||||
if m:
|
||||
env[m.group(1)] = m.group(2)
|
||||
return env
|
||||
|
||||
|
||||
def get_game_context(app_id: str) -> dict | None:
|
||||
"""Return {app_id, proton_exe, prefix, env} for a running game, or None."""
|
||||
game_dir = _runtime_dir() / app_id
|
||||
if not game_dir.is_dir():
|
||||
return None
|
||||
|
||||
ctx: dict = {"app_id": app_id}
|
||||
|
||||
exe_file = game_dir / "exe"
|
||||
if exe_file.exists():
|
||||
ctx["proton_exe"] = exe_file.read_text().strip()
|
||||
|
||||
pfx_file = game_dir / "pfx"
|
||||
if pfx_file.exists():
|
||||
ctx["prefix"] = pfx_file.read_text().strip()
|
||||
|
||||
env_file = game_dir / "env"
|
||||
if env_file.exists():
|
||||
ctx["env"] = _parse_bash_env(env_file.read_text())
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
# ── Run in Proton context ───────────────────────────────────────
|
||||
|
||||
|
||||
def run_in_proton(
|
||||
app_id: str,
|
||||
executable: str,
|
||||
args: list[str] | None = None,
|
||||
) -> subprocess.Popen | None:
|
||||
"""Launch *executable* inside the Proton context of *app_id*.
|
||||
|
||||
Args:
|
||||
app_id: Steam app ID.
|
||||
executable: Path to Windows executable.
|
||||
args: Optional list of arguments to pass to the executable.
|
||||
"""
|
||||
ctx = get_game_context(app_id)
|
||||
if not ctx or "proton_exe" not in ctx:
|
||||
return None
|
||||
|
||||
env = os.environ.copy()
|
||||
if "env" in ctx:
|
||||
env.update(ctx["env"])
|
||||
|
||||
proton_exe = ctx["proton_exe"]
|
||||
cmd = [proton_exe, "run", executable]
|
||||
if args:
|
||||
cmd.extend(args)
|
||||
return subprocess.Popen(
|
||||
cmd,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
|
||||
# ── Protonhax detection & management ────────────────────────────
|
||||
|
||||
|
||||
def find_installed() -> str | None:
|
||||
"""Return the path to protonhax (managed first, then system), or None."""
|
||||
if MANAGED_PATH.is_file():
|
||||
return str(MANAGED_PATH)
|
||||
return shutil.which("protonhax")
|
||||
|
||||
|
||||
def is_managed() -> bool:
|
||||
"""True if the managed copy exists."""
|
||||
return MANAGED_PATH.is_file()
|
||||
|
||||
|
||||
def _script_hash(text: str) -> str:
|
||||
return hashlib.sha256(text.encode()).hexdigest()
|
||||
|
||||
|
||||
def needs_update() -> bool:
|
||||
"""True if the managed copy exists but differs from the bundled version."""
|
||||
if not MANAGED_PATH.is_file():
|
||||
return False
|
||||
try:
|
||||
installed = MANAGED_PATH.read_text()
|
||||
except OSError:
|
||||
return True
|
||||
return _script_hash(installed) != _script_hash(INIT_SCRIPT)
|
||||
|
||||
|
||||
def ensure_installed() -> Path:
|
||||
"""Install the managed protonhax if missing or outdated. Returns its path."""
|
||||
if not MANAGED_PATH.is_file() or needs_update():
|
||||
return install_init_script(MANAGED_PATH)
|
||||
return MANAGED_PATH
|
||||
|
||||
|
||||
# ── Bundled init script ─────────────────────────────────────────
|
||||
|
||||
INIT_SCRIPT = r"""#!/bin/bash
|
||||
# protonhax — managed by Vual
|
||||
# https://github.com/jcnils/protonhax (original)
|
||||
|
||||
phd=${XDG_RUNTIME_DIR:-/run/user/$UID}/protonhax
|
||||
|
||||
usage() {
|
||||
echo "Usage:"
|
||||
echo "protonhax init <cmd>"
|
||||
printf "\tShould only be called by Steam with \"protonhax init %%COMMAND%%\"\n"
|
||||
echo "protonhax ls"
|
||||
printf "\tLists all currently running games\n"
|
||||
echo "protonhax run <appid> <cmd>"
|
||||
printf "\tRuns <cmd> in the context of <appid> with proton\n"
|
||||
echo "protonhax cmd <appid>"
|
||||
printf "\tRuns cmd.exe in the context of <appid>\n"
|
||||
echo "protonhax exec <appid> <cmd>"
|
||||
printf "\tRuns <cmd> in the context of <appid>\n"
|
||||
}
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
c=$1
|
||||
shift
|
||||
|
||||
if [[ "$c" == "init" ]]; then
|
||||
mkdir -p "$phd/$SteamAppId"
|
||||
printf "%s\n" "${@}" | grep -m 1 "/proton" > "$phd/$SteamAppId/exe"
|
||||
printf "%s" "$STEAM_COMPAT_DATA_PATH/pfx" > "$phd/$SteamAppId/pfx"
|
||||
declare -px > "$phd/$SteamAppId/env"
|
||||
"$@"
|
||||
ec=$?
|
||||
rm -r "$phd/$SteamAppId"
|
||||
exit $ec
|
||||
elif [[ "$c" == "ls" ]]; then
|
||||
if [[ -d "$phd" ]]; then
|
||||
ls -1 "$phd"
|
||||
fi
|
||||
elif [[ "$c" == "run" ]] || [[ "$c" == "cmd" ]] || [[ "$c" == "exec" ]]; then
|
||||
if [[ $# -lt 1 ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -d "$phd" ]]; then
|
||||
printf "No app running with appid \"%s\"\n" "$1"
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -d "$phd/$1" ]]; then
|
||||
printf "No app running with appid \"%s\"\n" "$1"
|
||||
exit 2
|
||||
fi
|
||||
SteamAppId=$1
|
||||
shift
|
||||
|
||||
source "$phd/$SteamAppId/env"
|
||||
|
||||
if [[ "$c" == "run" ]]; then
|
||||
if [[ $# -lt 1 ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
exec "$(cat "$phd/$SteamAppId/exe")" run "$@"
|
||||
elif [[ "$c" == "cmd" ]]; then
|
||||
exec "$(cat "$phd/$SteamAppId/exe")" run "$(cat "$phd/$SteamAppId/pfx")/drive_c/windows/system32/cmd.exe"
|
||||
elif [[ "$c" == "exec" ]]; then
|
||||
if [[ $# -lt 1 ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
exec "$@"
|
||||
fi
|
||||
else
|
||||
printf "Unknown command %s\n" "$c"
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
"""
|
||||
|
||||
|
||||
def install_init_script(target: Path | None = None) -> Path:
|
||||
"""Write the protonhax shell script to *target* and make it executable."""
|
||||
if target is None:
|
||||
target = MANAGED_PATH
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(INIT_SCRIPT)
|
||||
target.chmod(0o755)
|
||||
return target
|
||||
@@ -0,0 +1,538 @@
|
||||
"""Steam integration: VDF parsing, game discovery, and LaunchOptions management.
|
||||
|
||||
Core functionality:
|
||||
- VDF (Valve Data File) parsing and writing
|
||||
- Steam game discovery from appmanifest files
|
||||
- LaunchOptions manipulation in localconfig.vdf
|
||||
- Steam process control (detection, shutdown, restart)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
# ── VDF parser / writer ──────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_vdf(content: str) -> dict[str, object]:
|
||||
"""Parse Valve VDF text format into nested dictionaries.
|
||||
|
||||
VDF is a key-value format used by Valve in Steam configuration files.
|
||||
Supports nested sections (dictionaries) and string values.
|
||||
|
||||
Args:
|
||||
content: Raw VDF text content.
|
||||
|
||||
Returns:
|
||||
Nested dict structure representing the VDF data.
|
||||
|
||||
Example VDF:
|
||||
"UserLocalConfigStore"
|
||||
{
|
||||
"Software"
|
||||
{
|
||||
"Valve" "SomeValue"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
def tokenize(text: str) -> list[tuple[str, str]]:
|
||||
tokens: list[tuple[str, str]] = []
|
||||
i = 0
|
||||
while i < len(text):
|
||||
if text[i].isspace():
|
||||
i += 1
|
||||
elif text[i : i + 2] == "//":
|
||||
while i < len(text) and text[i] != "\n":
|
||||
i += 1
|
||||
elif text[i] == '"':
|
||||
i += 1
|
||||
start = i
|
||||
while i < len(text) and text[i] != '"':
|
||||
i += 1
|
||||
tokens.append(("S", text[start:i]))
|
||||
i += 1
|
||||
elif text[i] in "{}":
|
||||
tokens.append(("B", text[i]))
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
return tokens
|
||||
|
||||
def parse_tokens(tokens: list, idx: int = 0) -> tuple[dict, int]:
|
||||
result: dict = {}
|
||||
while idx < len(tokens):
|
||||
tt, tv = tokens[idx]
|
||||
if tt == "S":
|
||||
key = tv
|
||||
idx += 1
|
||||
if idx >= len(tokens):
|
||||
break
|
||||
nt, nv = tokens[idx]
|
||||
if nt == "B" and nv == "{":
|
||||
idx += 1
|
||||
nested, idx = parse_tokens(tokens, idx)
|
||||
result[key] = nested
|
||||
elif nt == "S":
|
||||
result[key] = nv
|
||||
idx += 1
|
||||
elif nt == "B" and nv == "}":
|
||||
break
|
||||
elif tt == "B" and tv == "}":
|
||||
idx += 1
|
||||
break
|
||||
else:
|
||||
idx += 1
|
||||
return result, idx
|
||||
|
||||
tokens = tokenize(content)
|
||||
parsed, _ = parse_tokens(tokens)
|
||||
return parsed
|
||||
|
||||
|
||||
def write_vdf(data: dict[str, object], indent: int = 0) -> str:
|
||||
"""Serialize nested dictionaries into Valve VDF text format.
|
||||
|
||||
Args:
|
||||
data: Dictionary to serialize.
|
||||
indent: Current indentation level (used internally for recursion).
|
||||
|
||||
Returns:
|
||||
VDF-formatted string.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
tab = "\t" * indent
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict):
|
||||
lines.append(f'{tab}"{key}"')
|
||||
lines.append(f"{tab}{{")
|
||||
lines.append(write_vdf(value, indent + 1).rstrip())
|
||||
lines.append(f"{tab}}}")
|
||||
else:
|
||||
lines.append(f'{tab}"{key}"\t\t"{value}"')
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Config Discovery
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def find_localconfig_vdf(steam_path: str) -> Path | None:
|
||||
"""Find the first localconfig.vdf file in Steam's userdata directory.
|
||||
|
||||
This file contains per-user Steam settings including LaunchOptions
|
||||
for each game.
|
||||
|
||||
Args:
|
||||
steam_path: Path to Steam installation directory (can use ~).
|
||||
|
||||
Returns:
|
||||
Path to localconfig.vdf if found, None otherwise.
|
||||
"""
|
||||
userdata = Path(steam_path).expanduser() / "userdata"
|
||||
if not userdata.exists():
|
||||
return None
|
||||
for cfg in userdata.rglob("localconfig.vdf"):
|
||||
return cfg
|
||||
return None
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Game Manifest Reading
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
|
||||
# Cache for compiled regex patterns to avoid recompilation
|
||||
_EXCLUDE_PATTERNS_CACHE: dict[tuple[str, ...], list[re.Pattern[str]]] = {}
|
||||
|
||||
|
||||
def _get_compiled_patterns(patterns: Sequence[str] | None) -> list[re.Pattern[str]]:
|
||||
"""Compile and cache exclusion regex patterns."""
|
||||
if not patterns:
|
||||
return []
|
||||
key = tuple(patterns)
|
||||
if key not in _EXCLUDE_PATTERNS_CACHE:
|
||||
_EXCLUDE_PATTERNS_CACHE[key] = [re.compile(p, re.IGNORECASE) for p in patterns]
|
||||
return _EXCLUDE_PATTERNS_CACHE[key]
|
||||
|
||||
|
||||
def is_app_excluded(app_name: str, patterns: Sequence[str] | None) -> bool:
|
||||
"""Check if app name matches any exclusion pattern.
|
||||
|
||||
Args:
|
||||
app_name: Name of the Steam app.
|
||||
patterns: List of regex patterns to check against.
|
||||
|
||||
Returns:
|
||||
True if app should be excluded.
|
||||
"""
|
||||
return any(pat.search(app_name) for pat in _get_compiled_patterns(patterns))
|
||||
|
||||
|
||||
def get_game_info(app_id: str, steamapps: Path) -> dict[str, str]:
|
||||
"""Read game information from appmanifest_<id>.acf.
|
||||
|
||||
Args:
|
||||
app_id: Steam app ID.
|
||||
steamapps: Path to steamapps directory.
|
||||
|
||||
Returns:
|
||||
Dict with keys: app_id, name, install_dir.
|
||||
"""
|
||||
info: dict[str, str] = {"app_id": app_id, "name": f"Game {app_id}", "install_dir": ""}
|
||||
manifest = steamapps / f"appmanifest_{app_id}.acf"
|
||||
if not manifest.exists():
|
||||
return info
|
||||
|
||||
try:
|
||||
text = manifest.read_text()
|
||||
except OSError:
|
||||
return info
|
||||
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith('"name"'):
|
||||
parts = line.split('"', 3)
|
||||
if len(parts) >= 4:
|
||||
info["name"] = parts[3].rstrip('"')
|
||||
elif line.startswith('"installdir"'):
|
||||
parts = line.split('"', 3)
|
||||
if len(parts) >= 4:
|
||||
info["install_dir"] = parts[3].rstrip('"')
|
||||
return info
|
||||
|
||||
|
||||
def get_installed_games(
|
||||
steamapps: Path,
|
||||
excluded_patterns: Sequence[str] | None = None,
|
||||
) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
|
||||
"""Get lists of installed Steam games.
|
||||
|
||||
Args:
|
||||
steamapps: Path to steamapps directory.
|
||||
excluded_patterns: Regex patterns for apps to exclude.
|
||||
|
||||
Returns:
|
||||
Tuple of (included_games, excluded_games), sorted by name.
|
||||
"""
|
||||
included: list[dict[str, str]] = []
|
||||
excluded: list[dict[str, str]] = []
|
||||
|
||||
if not steamapps.exists():
|
||||
return included, excluded
|
||||
for mf in steamapps.glob("appmanifest_*.acf"):
|
||||
aid = mf.stem.replace("appmanifest_", "")
|
||||
if not aid.isdigit():
|
||||
continue
|
||||
info = get_game_info(aid, steamapps)
|
||||
if is_app_excluded(info["name"], excluded_patterns):
|
||||
excluded.append(info)
|
||||
else:
|
||||
included.append(info)
|
||||
included.sort(key=lambda g: g["name"].lower())
|
||||
excluded.sort(key=lambda g: g["name"].lower())
|
||||
return included, excluded
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# LaunchOptions Management
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
|
||||
# VDF path to the apps section
|
||||
_VDF_APP_PATH = ["UserLocalConfigStore", "Software", "Valve", "Steam", "apps"]
|
||||
|
||||
|
||||
def _navigate_to_apps(data: dict[str, object]) -> dict[str, object] | None:
|
||||
"""Navigate parsed VDF dict to the apps section.
|
||||
|
||||
Path: UserLocalConfigStore -> Software -> Valve -> Steam -> apps
|
||||
"""
|
||||
try:
|
||||
root = data.get("UserLocalConfigStore", data)
|
||||
return root.get("Software", {}).get("Valve", {}).get("Steam", {}).get("apps", {})
|
||||
except (TypeError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def _navigate_vdf_lines(lines: list[str], path: list[str]) -> tuple[int, int]:
|
||||
"""Navigate VDF text lines to find a section by key path.
|
||||
|
||||
Args:
|
||||
lines: VDF file content split by lines.
|
||||
path: List of section keys to navigate.
|
||||
|
||||
Returns:
|
||||
Tuple of (brace_line_index, brace_depth) for the opening brace
|
||||
of the target section, or (-1, -1) if not found.
|
||||
"""
|
||||
target_idx = 0
|
||||
brace_depth = 0
|
||||
pending_key: str | None = None
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("//"):
|
||||
continue
|
||||
|
||||
if stripped == "{":
|
||||
brace_depth += 1
|
||||
if (
|
||||
pending_key is not None
|
||||
and target_idx < len(path)
|
||||
and pending_key == path[target_idx]
|
||||
and brace_depth == target_idx + 1
|
||||
):
|
||||
target_idx += 1
|
||||
if target_idx == len(path):
|
||||
return i, brace_depth
|
||||
pending_key = None
|
||||
continue
|
||||
|
||||
if stripped == "}":
|
||||
brace_depth -= 1
|
||||
pending_key = None
|
||||
continue
|
||||
|
||||
m = re.match(r'^"([^"]*)"', stripped)
|
||||
if not m:
|
||||
pending_key = None
|
||||
continue
|
||||
|
||||
rest = stripped[m.end() :].strip()
|
||||
if rest:
|
||||
# key-value pair on one line
|
||||
pending_key = None
|
||||
else:
|
||||
# section header — next line should be "{"
|
||||
pending_key = m.group(1)
|
||||
|
||||
return -1, -1
|
||||
|
||||
|
||||
def get_launch_options(app_id: str, localconfig_path: Path) -> str | None:
|
||||
"""Return LaunchOptions string for app_id, or None if not set."""
|
||||
try:
|
||||
data = parse_vdf(localconfig_path.read_text())
|
||||
except OSError:
|
||||
return None
|
||||
apps = _navigate_to_apps(data)
|
||||
if not apps:
|
||||
return None
|
||||
section = apps.get(app_id) if isinstance(apps, dict) else None
|
||||
if isinstance(section, dict):
|
||||
return section.get("LaunchOptions")
|
||||
return None
|
||||
|
||||
|
||||
def set_launch_options(app_id: str, value: str, localconfig_path: Path) -> bool:
|
||||
"""Set LaunchOptions for *app_id* via surgical text edit (no full reserialization)."""
|
||||
try:
|
||||
text = localconfig_path.read_text()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
old = get_launch_options(app_id, localconfig_path)
|
||||
if old and old != value:
|
||||
_create_backup(app_id, old)
|
||||
|
||||
lines = text.splitlines(keepends=True)
|
||||
app_path = _VDF_APP_PATH + [app_id]
|
||||
brace_idx, brace_depth = _navigate_vdf_lines(lines, app_path)
|
||||
|
||||
if brace_idx >= 0:
|
||||
# App section exists — find LaunchOptions or closing '}'
|
||||
launch_idx = -1
|
||||
close_idx = -1
|
||||
depth = brace_depth
|
||||
|
||||
for i in range(brace_idx + 1, len(lines)):
|
||||
stripped = lines[i].strip()
|
||||
if not stripped or stripped.startswith("//"):
|
||||
continue
|
||||
if stripped == "{":
|
||||
depth += 1
|
||||
continue
|
||||
if stripped == "}":
|
||||
if depth == brace_depth:
|
||||
close_idx = i
|
||||
break
|
||||
depth -= 1
|
||||
continue
|
||||
if depth == brace_depth:
|
||||
km = re.match(r'^"([^"]*)"', stripped)
|
||||
if km and km.group(1) == "LaunchOptions":
|
||||
launch_idx = i
|
||||
break
|
||||
|
||||
if launch_idx >= 0:
|
||||
old_line = lines[launch_idx]
|
||||
indent = old_line[: len(old_line) - len(old_line.lstrip())]
|
||||
lines[launch_idx] = f'{indent}"LaunchOptions"\t\t"{value}"\n'
|
||||
elif close_idx >= 0:
|
||||
cl = lines[close_idx]
|
||||
indent = cl[: len(cl) - len(cl.lstrip())] + "\t"
|
||||
lines.insert(close_idx, f'{indent}"LaunchOptions"\t\t"{value}"\n')
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
# App section missing — create it inside "apps"
|
||||
apps_idx, apps_depth = _navigate_vdf_lines(lines, _VDF_APP_PATH)
|
||||
if apps_idx < 0:
|
||||
return False
|
||||
ai = lines[apps_idx][: len(lines[apps_idx]) - len(lines[apps_idx].lstrip())]
|
||||
ki = ai + "\t"
|
||||
vi = ki + "\t"
|
||||
block = [
|
||||
f'{ki}"{app_id}"\n',
|
||||
f"{ki}{{\n",
|
||||
f'{vi}"LaunchOptions"\t\t"{value}"\n',
|
||||
f"{ki}}}\n",
|
||||
]
|
||||
for j, nl in enumerate(block):
|
||||
lines.insert(apps_idx + 1 + j, nl)
|
||||
|
||||
try:
|
||||
localconfig_path.write_text("".join(lines))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def remove_launch_options(app_id: str, localconfig_path: Path) -> bool:
|
||||
"""Remove LaunchOptions for *app_id* via surgical text edit."""
|
||||
try:
|
||||
text = localconfig_path.read_text()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
old = get_launch_options(app_id, localconfig_path)
|
||||
if not old:
|
||||
return False
|
||||
|
||||
_create_backup(app_id, old)
|
||||
|
||||
lines = text.splitlines(keepends=True)
|
||||
app_path = _VDF_APP_PATH + [app_id]
|
||||
brace_idx, brace_depth = _navigate_vdf_lines(lines, app_path)
|
||||
if brace_idx < 0:
|
||||
return False
|
||||
|
||||
depth = brace_depth
|
||||
for i in range(brace_idx + 1, len(lines)):
|
||||
stripped = lines[i].strip()
|
||||
if not stripped or stripped.startswith("//"):
|
||||
continue
|
||||
if stripped == "{":
|
||||
depth += 1
|
||||
continue
|
||||
if stripped == "}":
|
||||
if depth == brace_depth:
|
||||
break
|
||||
depth -= 1
|
||||
continue
|
||||
if depth == brace_depth:
|
||||
km = re.match(r'^"([^"]*)"', stripped)
|
||||
if km and km.group(1) == "LaunchOptions":
|
||||
del lines[i]
|
||||
try:
|
||||
localconfig_path.write_text("".join(lines))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _create_backup(game_id: str, original: str) -> None:
|
||||
"""Create a backup file with the original LaunchOptions value."""
|
||||
backup_dir = Path.home() / ".local" / "share" / "vual" / "backups"
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup = backup_dir / f"launch_options_backup_{ts}.md"
|
||||
line = f"| {game_id} | {original} |\n"
|
||||
|
||||
if backup.exists():
|
||||
with open(backup, "a") as f:
|
||||
f.write(line)
|
||||
else:
|
||||
with open(backup, "w") as f:
|
||||
f.write("# Launch Options Backup\n\n")
|
||||
f.write(f"Generated: {datetime.now().isoformat()}\n\n")
|
||||
f.write("| Game ID | Original LaunchOptions |\n")
|
||||
f.write("|---------|------------------------|\n")
|
||||
f.write(line)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Steam Process Control
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def is_steam_running() -> bool:
|
||||
"""Check if the Steam client process is currently running.
|
||||
|
||||
Uses /proc filesystem to detect Steam process.
|
||||
Excludes steamwebhelper processes.
|
||||
|
||||
Returns:
|
||||
True if Steam main process is running.
|
||||
"""
|
||||
proc = Path("/proc")
|
||||
if not proc.is_dir():
|
||||
return False
|
||||
for pid_dir in proc.iterdir():
|
||||
if not pid_dir.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
cmdline = (pid_dir / "cmdline").read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
first_arg = cmdline.split(b"\x00", 1)[0]
|
||||
if first_arg.endswith(b"/steam") and b"steamwebhelper" not in cmdline:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def shutdown_steam() -> None:
|
||||
"""Request a graceful Steam shutdown via CLI command."""
|
||||
subprocess.Popen(
|
||||
["steam", "-shutdown"],
|
||||
start_new_session=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def wait_steam_exit(timeout: float = 30.0) -> bool:
|
||||
"""Wait for Steam to exit.
|
||||
|
||||
Args:
|
||||
timeout: Maximum time to wait in seconds.
|
||||
|
||||
Returns:
|
||||
True if Steam exited, False if timeout reached.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if not is_steam_running():
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
def start_steam() -> None:
|
||||
"""Launch Steam in the background."""
|
||||
subprocess.Popen(
|
||||
["steam"],
|
||||
start_new_session=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Vual UI sub-package."""
|
||||
@@ -0,0 +1,816 @@
|
||||
"""Main games grid page.
|
||||
|
||||
Clean rewrite using Gtk.FlowBox + simple Gtk.Overlay tiles.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
gi.require_version("GdkPixbuf", "2.0")
|
||||
gi.require_version("Pango", "1.0")
|
||||
|
||||
from gi.repository import Adw, Gdk, GdkPixbuf, GLib, Gtk, Pango
|
||||
|
||||
import requests
|
||||
|
||||
from vual import protonhax, steam
|
||||
from vual.config import TILE_SIZES, Config
|
||||
from vual.i18n import _
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vual.window import VualWindow
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Constants
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
COVER_RATIO = 1.5 # Height = Width * 1.5 (portrait)
|
||||
GRID_SPACING = 12 # Spacing between tiles
|
||||
GRID_MARGIN = 16 # Margin around grid
|
||||
|
||||
# Steam cover URLs
|
||||
COVER_URL = "https://steamcdn-a.akamaihd.net/steam/apps/{}/library_600x900_2x.jpg"
|
||||
COVER_FALLBACK = "https://steamcdn-a.akamaihd.net/steam/apps/{}/header.jpg"
|
||||
|
||||
# Cache directory
|
||||
CACHE_DIR = Path(GLib.get_user_cache_dir()) / "vual" / "covers"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Cover loading utilities
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _cache_path(app_id: str) -> Path:
|
||||
"""Get cache path for cover image."""
|
||||
return CACHE_DIR / f"{app_id}.jpg"
|
||||
|
||||
|
||||
def _download_cover(app_id: str) -> Path | None:
|
||||
"""Download cover from Steam CDN. Returns cache path or None."""
|
||||
cache = _cache_path(app_id)
|
||||
if cache.exists():
|
||||
return cache
|
||||
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for url_template in (COVER_URL, COVER_FALLBACK):
|
||||
url = url_template.format(app_id)
|
||||
try:
|
||||
resp = requests.get(url, headers={"User-Agent": "Vual/1.0"}, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
cache.write_bytes(resp.content)
|
||||
return cache
|
||||
except requests.RequestException:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _load_pixbuf(path: Path, width: int, height: int) -> GdkPixbuf.Pixbuf | None:
|
||||
"""Load and scale pixbuf from file.
|
||||
|
||||
Handles landscape images by compositing them onto a blurred background.
|
||||
"""
|
||||
try:
|
||||
original = GdkPixbuf.Pixbuf.new_from_file(str(path))
|
||||
except GLib.Error:
|
||||
return None
|
||||
|
||||
ow, oh = original.get_width(), original.get_height()
|
||||
if ow < 1 or oh < 1:
|
||||
return None
|
||||
|
||||
aspect = ow / oh
|
||||
|
||||
# If image is landscape (wider than 4:3), create composite with blur
|
||||
if aspect > 1.33:
|
||||
return _create_landscape_composite(original, ow, oh, width, height)
|
||||
|
||||
# Portrait or square: scale to fill
|
||||
return original.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
|
||||
|
||||
|
||||
def _create_landscape_composite(
|
||||
original: GdkPixbuf.Pixbuf,
|
||||
ow: int, oh: int,
|
||||
width: int, height: int
|
||||
) -> GdkPixbuf.Pixbuf:
|
||||
"""Create a portrait tile from a landscape image.
|
||||
|
||||
Composites the original image centered on a scaled/blurred background.
|
||||
"""
|
||||
# Create background: scale original to fill tile (will be cropped/stretched)
|
||||
bg = original.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
|
||||
|
||||
# Apply simple darkening to background (simulates blur effect)
|
||||
# We darken by compositing with a semi-transparent black
|
||||
dark = GdkPixbuf.Pixbuf.new(
|
||||
GdkPixbuf.Colorspace.RGB, True, 8, width, height
|
||||
)
|
||||
dark.fill(0x00000099) # Black with ~60% opacity
|
||||
dark.composite(
|
||||
bg, 0, 0, width, height,
|
||||
0, 0, 1.0, 1.0,
|
||||
GdkPixbuf.InterpType.NEAREST, 180
|
||||
)
|
||||
|
||||
# Scale original to fit width while preserving aspect ratio
|
||||
scale = width / ow
|
||||
scaled_w = width
|
||||
scaled_h = int(oh * scale)
|
||||
|
||||
scaled = original.scale_simple(scaled_w, scaled_h, GdkPixbuf.InterpType.BILINEAR)
|
||||
|
||||
# Center vertically
|
||||
y_offset = (height - scaled_h) // 2
|
||||
|
||||
# Composite scaled image onto darkened background
|
||||
scaled.composite(
|
||||
bg,
|
||||
0, y_offset, # dest x, y
|
||||
scaled_w, scaled_h, # dest width, height
|
||||
0, y_offset, # offset x, y
|
||||
1.0, 1.0, # scale x, y
|
||||
GdkPixbuf.InterpType.BILINEAR,
|
||||
255 # full opacity
|
||||
)
|
||||
|
||||
return bg
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# GameTile — single game tile widget
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class GameTile(Gtk.Overlay):
|
||||
"""A single game tile with cover image and controls overlay."""
|
||||
|
||||
__gtype_name__ = "VualGameTile"
|
||||
|
||||
def __init__(self, app_id: str, name: str, is_active: bool, tile_width: int):
|
||||
super().__init__()
|
||||
|
||||
self._app_id = app_id
|
||||
self._name = name
|
||||
self._is_active = is_active
|
||||
self._is_running = False
|
||||
self._width = tile_width
|
||||
self._height = int(tile_width * COVER_RATIO)
|
||||
|
||||
# Fixed size container
|
||||
self.set_size_request(self._width, self._height)
|
||||
self.add_css_class("vual-tile")
|
||||
self.set_overflow(Gtk.Overflow.HIDDEN)
|
||||
|
||||
# Cover image (fills the tile)
|
||||
self._picture = Gtk.Picture()
|
||||
self._picture.set_content_fit(Gtk.ContentFit.COVER)
|
||||
self._picture.set_size_request(self._width, self._height)
|
||||
self._picture.add_css_class("skeleton")
|
||||
self.set_child(self._picture)
|
||||
|
||||
# Bottom info overlay
|
||||
self._build_info_box()
|
||||
|
||||
# Top-left reload button
|
||||
self._build_reload_button()
|
||||
|
||||
# Top-right running badge
|
||||
self._build_running_badge()
|
||||
|
||||
def _build_info_box(self) -> None:
|
||||
"""Create bottom info panel with name, buttons, and switch."""
|
||||
info = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||
info.add_css_class("vual-tile-info")
|
||||
info.set_valign(Gtk.Align.END)
|
||||
info.set_halign(Gtk.Align.FILL)
|
||||
|
||||
# Game name
|
||||
label = Gtk.Label(label=self._name)
|
||||
label.set_ellipsize(Pango.EllipsizeMode.END)
|
||||
label.set_max_width_chars(15)
|
||||
label.set_halign(Gtk.Align.START)
|
||||
label.add_css_class("heading")
|
||||
info.append(label)
|
||||
|
||||
# Controls row: [Play] [CE] ---- [Switch]
|
||||
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
row.set_halign(Gtk.Align.FILL)
|
||||
|
||||
# Play button
|
||||
btn_play = Gtk.Button()
|
||||
btn_play.set_icon_name("media-playback-start-symbolic")
|
||||
btn_play.add_css_class("flat")
|
||||
btn_play.add_css_class("tile-btn")
|
||||
btn_play.set_tooltip_text(_("Launch game"))
|
||||
btn_play.connect("clicked", self._on_play_clicked)
|
||||
row.append(btn_play)
|
||||
|
||||
# CE button
|
||||
btn_ce = Gtk.Button()
|
||||
btn_ce.set_icon_name("utilities-terminal-symbolic")
|
||||
btn_ce.add_css_class("flat")
|
||||
btn_ce.add_css_class("tile-btn")
|
||||
btn_ce.set_tooltip_text(_("Launch Cheat Engine"))
|
||||
btn_ce.connect("clicked", self._on_ce_clicked)
|
||||
row.append(btn_ce)
|
||||
|
||||
# Spacer
|
||||
spacer = Gtk.Box()
|
||||
spacer.set_hexpand(True)
|
||||
row.append(spacer)
|
||||
|
||||
# Active switch
|
||||
self._switch = Gtk.Switch()
|
||||
self._switch.set_valign(Gtk.Align.CENTER)
|
||||
self._switch.set_active(self._is_active)
|
||||
self._switch.connect("state-set", self._on_switch_toggled)
|
||||
row.append(self._switch)
|
||||
|
||||
info.append(row)
|
||||
self.add_overlay(info)
|
||||
|
||||
def _build_reload_button(self) -> None:
|
||||
"""Create reload button in top-left corner."""
|
||||
btn = Gtk.Button()
|
||||
btn.set_icon_name("view-refresh-symbolic")
|
||||
btn.add_css_class("flat")
|
||||
btn.add_css_class("vual-reload-btn")
|
||||
btn.set_valign(Gtk.Align.START)
|
||||
btn.set_halign(Gtk.Align.START)
|
||||
btn.set_margin_top(6)
|
||||
btn.set_margin_start(6)
|
||||
btn.set_tooltip_text(_("Refresh cover"))
|
||||
btn.connect("clicked", self._on_reload_clicked)
|
||||
self.add_overlay(btn)
|
||||
|
||||
def _build_running_badge(self) -> None:
|
||||
"""Create running badge in top-right corner."""
|
||||
self._badge = Gtk.Image.new_from_icon_name("media-playback-start-symbolic")
|
||||
self._badge.set_pixel_size(14)
|
||||
self._badge.add_css_class("vual-badge")
|
||||
self._badge.set_valign(Gtk.Align.START)
|
||||
self._badge.set_halign(Gtk.Align.END)
|
||||
self._badge.set_margin_top(6)
|
||||
self._badge.set_margin_end(6)
|
||||
self._badge.set_visible(False)
|
||||
self.add_overlay(self._badge)
|
||||
|
||||
# ─── Properties ───────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def app_id(self) -> str:
|
||||
return self._app_id
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self._is_active
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._is_running
|
||||
|
||||
# ─── Setters ──────────────────────────────────────────────────────────────
|
||||
|
||||
def set_active_silent(self, active: bool) -> None:
|
||||
"""Set switch without triggering callback."""
|
||||
self._is_active = active
|
||||
self._switch.handler_block_by_func(self._on_switch_toggled)
|
||||
self._switch.set_active(active)
|
||||
self._switch.handler_unblock_by_func(self._on_switch_toggled)
|
||||
|
||||
def set_running(self, running: bool) -> None:
|
||||
"""Update running badge visibility."""
|
||||
self._is_running = running
|
||||
self._badge.set_visible(running)
|
||||
|
||||
def set_cover(self, pixbuf: GdkPixbuf.Pixbuf | None) -> None:
|
||||
"""Set cover image from pixbuf."""
|
||||
self._picture.remove_css_class("skeleton")
|
||||
if pixbuf:
|
||||
texture = Gdk.Texture.new_for_pixbuf(pixbuf)
|
||||
self._picture.set_paintable(texture)
|
||||
else:
|
||||
self._picture.set_paintable(None)
|
||||
|
||||
def resize(self, width: int) -> None:
|
||||
"""Resize tile to new width."""
|
||||
self._width = width
|
||||
self._height = int(width * COVER_RATIO)
|
||||
self.set_size_request(self._width, self._height)
|
||||
self._picture.set_size_request(self._width, self._height)
|
||||
|
||||
# ─── Callbacks ────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_play_clicked(self, _btn: Gtk.Button) -> None:
|
||||
"""Emit play signal."""
|
||||
if hasattr(self, "_on_launch"):
|
||||
self._on_launch(self._app_id, self._name)
|
||||
|
||||
def _on_ce_clicked(self, _btn: Gtk.Button) -> None:
|
||||
"""Emit CE signal."""
|
||||
if hasattr(self, "_on_launch_ce"):
|
||||
self._on_launch_ce(self._app_id, self._name)
|
||||
|
||||
def _on_switch_toggled(self, switch: Gtk.Switch, state: bool) -> bool:
|
||||
"""Handle activation toggle."""
|
||||
self._is_active = state
|
||||
if hasattr(self, "_on_toggle"):
|
||||
self._on_toggle(self._app_id, state)
|
||||
return False
|
||||
|
||||
def _on_reload_clicked(self, _btn: Gtk.Button) -> None:
|
||||
"""Emit reload signal."""
|
||||
if hasattr(self, "_on_reload"):
|
||||
self._on_reload(self._app_id)
|
||||
|
||||
# ─── Connect handlers ─────────────────────────────────────────────────────
|
||||
|
||||
def connect_launch(self, callback) -> None:
|
||||
self._on_launch = callback
|
||||
|
||||
def connect_launch_ce(self, callback) -> None:
|
||||
self._on_launch_ce = callback
|
||||
|
||||
def connect_toggle(self, callback) -> None:
|
||||
self._on_toggle = callback
|
||||
|
||||
def connect_reload(self, callback) -> None:
|
||||
self._on_reload = callback
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# MainPage — the main games grid
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class MainPage(Adw.Bin):
|
||||
"""Main page with games grid."""
|
||||
|
||||
__gtype_name__ = "VualMainPage"
|
||||
|
||||
def __init__(self, config: Config, window: VualWindow):
|
||||
super().__init__()
|
||||
|
||||
self._win = window
|
||||
self._cfg = config
|
||||
self._tiles: dict[str, GameTile] = {}
|
||||
self._search_text = ""
|
||||
self._loading = False
|
||||
self._auto_ce: str | None = None
|
||||
self._executor = ThreadPoolExecutor(max_workers=8)
|
||||
|
||||
# Search entry (exposed for header bar)
|
||||
self._search = Gtk.SearchEntry()
|
||||
self._search.set_placeholder_text(_("Search..."))
|
||||
self._search.connect("search-changed", self._on_search_changed)
|
||||
|
||||
# Build UI
|
||||
self._build_ui()
|
||||
|
||||
# Load games on startup
|
||||
GLib.idle_add(self._load)
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
"""Build the main page UI."""
|
||||
# Stack for loading/content states
|
||||
self._stack = Gtk.Stack()
|
||||
self._stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
|
||||
self.set_child(self._stack)
|
||||
|
||||
# Loading spinner
|
||||
spinner = Gtk.Spinner(spinning=True)
|
||||
spinner.set_size_request(48, 48)
|
||||
spinner.set_valign(Gtk.Align.CENTER)
|
||||
spinner.set_halign(Gtk.Align.CENTER)
|
||||
self._stack.add_named(spinner, "loading")
|
||||
|
||||
# Empty state
|
||||
empty = Adw.StatusPage(
|
||||
icon_name="view-grid-symbolic",
|
||||
title=_("Games not found"),
|
||||
description=_("Check Steam path in settings"),
|
||||
)
|
||||
self._stack.add_named(empty, "empty")
|
||||
|
||||
# Scrolled grid
|
||||
scroll = Gtk.ScrolledWindow()
|
||||
scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||||
scroll.set_vexpand(True)
|
||||
|
||||
# FlowBox grid
|
||||
self._grid = Gtk.FlowBox()
|
||||
self._grid.set_homogeneous(False)
|
||||
self._grid.set_valign(Gtk.Align.START)
|
||||
self._grid.set_halign(Gtk.Align.CENTER)
|
||||
self._grid.set_row_spacing(GRID_SPACING)
|
||||
self._grid.set_column_spacing(GRID_SPACING)
|
||||
self._grid.set_selection_mode(Gtk.SelectionMode.NONE)
|
||||
self._grid.set_min_children_per_line(1)
|
||||
self._grid.set_max_children_per_line(20)
|
||||
self._grid.set_margin_start(GRID_MARGIN)
|
||||
self._grid.set_margin_end(GRID_MARGIN)
|
||||
self._grid.set_margin_top(GRID_MARGIN)
|
||||
self._grid.set_margin_bottom(GRID_MARGIN)
|
||||
self._grid.add_css_class("vual-grid")
|
||||
self._grid.set_filter_func(self._filter_func)
|
||||
self._grid.set_sort_func(self._sort_func)
|
||||
|
||||
scroll.set_child(self._grid)
|
||||
self._stack.add_named(scroll, "games")
|
||||
|
||||
self._stack.set_visible_child_name("loading")
|
||||
|
||||
# ─── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Reload games list."""
|
||||
self._load_games()
|
||||
|
||||
# Alias for window.py compatibility
|
||||
_load = reload
|
||||
|
||||
def search(self, text: str) -> None:
|
||||
"""Filter games by search text."""
|
||||
self._search_text = text.lower().strip()
|
||||
self._grid.invalidate_filter()
|
||||
|
||||
def _on_search_changed(self, entry: Gtk.SearchEntry) -> None:
|
||||
"""Handle search entry changes."""
|
||||
self.search(entry.get_text())
|
||||
|
||||
def apply_tile_size(self) -> None:
|
||||
"""Apply current tile size from config."""
|
||||
width = TILE_SIZES.get(self._cfg.tile_size, 150)
|
||||
for tile in self._tiles.values():
|
||||
tile.resize(width)
|
||||
# Reload cover at new size
|
||||
self._load_cover(tile)
|
||||
|
||||
def apply_sort(self) -> None:
|
||||
"""Reapply sort order."""
|
||||
self._grid.invalidate_sort()
|
||||
|
||||
# Alias for window.py compatibility
|
||||
_update_sort = apply_sort
|
||||
|
||||
def update_running_state(self, running_ids: set[str]) -> None:
|
||||
"""Update running badges based on active processes."""
|
||||
for app_id, tile in self._tiles.items():
|
||||
tile.set_running(app_id in running_ids)
|
||||
|
||||
@property
|
||||
def games_count(self) -> int:
|
||||
"""Number of loaded games."""
|
||||
return len(self._tiles)
|
||||
|
||||
# ─── Filter & Sort ────────────────────────────────────────────────────────
|
||||
|
||||
def _filter_func(self, child: Gtk.FlowBoxChild) -> bool:
|
||||
"""Filter function for FlowBox."""
|
||||
if not self._search_text:
|
||||
return True
|
||||
tile = child.get_child()
|
||||
if isinstance(tile, GameTile):
|
||||
return self._search_text in tile.name.lower()
|
||||
return True
|
||||
|
||||
def _sort_func(self, a: Gtk.FlowBoxChild, b: Gtk.FlowBoxChild) -> int:
|
||||
"""Sort function for FlowBox."""
|
||||
tile_a = a.get_child()
|
||||
tile_b = b.get_child()
|
||||
if not isinstance(tile_a, GameTile) or not isinstance(tile_b, GameTile):
|
||||
return 0
|
||||
|
||||
if self._cfg.sort_by == "status":
|
||||
# Active first, then by name
|
||||
if tile_a.is_active != tile_b.is_active:
|
||||
return -1 if tile_a.is_active else 1
|
||||
|
||||
# Alphabetical
|
||||
return (tile_a.name.lower() > tile_b.name.lower()) - (tile_a.name.lower() < tile_b.name.lower())
|
||||
|
||||
# ─── Loading ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_games(self) -> None:
|
||||
"""Load games from Steam in background thread."""
|
||||
if self._loading:
|
||||
return
|
||||
self._loading = True
|
||||
self._stack.set_visible_child_name("loading")
|
||||
|
||||
def worker():
|
||||
steamapps = self._cfg.steamapps_path
|
||||
if not steamapps.exists():
|
||||
GLib.idle_add(self._on_games_loaded, [])
|
||||
return
|
||||
|
||||
# Get installed games using steam module
|
||||
included, _ = steam.get_installed_games(
|
||||
steamapps, self._cfg.excluded_app_patterns
|
||||
)
|
||||
|
||||
# Check activation status for each game
|
||||
localconfig = steam.find_localconfig_vdf(self._cfg.steam_path)
|
||||
games = []
|
||||
for game in included:
|
||||
app_id = game["app_id"]
|
||||
name = game["name"]
|
||||
|
||||
is_active = False
|
||||
if localconfig:
|
||||
opts = steam.get_launch_options(app_id, localconfig)
|
||||
if opts and "protonhax" in opts.lower():
|
||||
is_active = True
|
||||
|
||||
games.append((app_id, name, is_active))
|
||||
|
||||
GLib.idle_add(self._on_games_loaded, games)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _on_games_loaded(self, games: list[tuple[str, str, bool]]) -> None:
|
||||
"""Handle loaded games on main thread."""
|
||||
self._loading = False
|
||||
|
||||
# Clear existing tiles
|
||||
while child := self._grid.get_first_child():
|
||||
self._grid.remove(child)
|
||||
self._tiles.clear()
|
||||
|
||||
if not games:
|
||||
self._stack.set_visible_child_name("empty")
|
||||
self._win.update_counter(0)
|
||||
return
|
||||
|
||||
# Get tile size
|
||||
width = TILE_SIZES.get(self._cfg.tile_size, 150)
|
||||
|
||||
# Create tiles
|
||||
for app_id, name, is_active in games:
|
||||
tile = GameTile(app_id, name, is_active, width)
|
||||
tile.connect_launch(self._on_launch)
|
||||
tile.connect_launch_ce(self._on_launch_ce)
|
||||
tile.connect_toggle(self._on_toggle)
|
||||
tile.connect_reload(self._on_reload_cover)
|
||||
|
||||
self._tiles[app_id] = tile
|
||||
self._grid.append(tile)
|
||||
|
||||
# Load cover in background
|
||||
self._load_cover(tile)
|
||||
|
||||
self._grid.invalidate_sort()
|
||||
self._stack.set_visible_child_name("games")
|
||||
self._win.update_counter(len(games))
|
||||
|
||||
# Check running games
|
||||
self._check_running()
|
||||
|
||||
def _load_cover(self, tile: GameTile) -> None:
|
||||
"""Load cover image for tile in background."""
|
||||
app_id = tile.app_id
|
||||
width = tile._width
|
||||
height = tile._height
|
||||
|
||||
def worker():
|
||||
path = _download_cover(app_id)
|
||||
if path:
|
||||
pixbuf = _load_pixbuf(path, width, height)
|
||||
GLib.idle_add(tile.set_cover, pixbuf)
|
||||
else:
|
||||
GLib.idle_add(tile.set_cover, None)
|
||||
|
||||
self._executor.submit(worker)
|
||||
|
||||
def _on_reload_cover(self, app_id: str) -> None:
|
||||
"""Force reload cover from Steam."""
|
||||
tile = self._tiles.get(app_id)
|
||||
if not tile:
|
||||
return
|
||||
|
||||
# Delete cached cover
|
||||
cache = _cache_path(app_id)
|
||||
if cache.exists():
|
||||
cache.unlink()
|
||||
|
||||
# Re-add skeleton class and reload
|
||||
tile._picture.add_css_class("skeleton")
|
||||
tile._picture.set_paintable(None)
|
||||
self._load_cover(tile)
|
||||
|
||||
# ─── Running detection ────────────────────────────────────────────────────
|
||||
|
||||
def _check_running(self) -> None:
|
||||
"""Check which games are currently running."""
|
||||
def worker():
|
||||
running_ids = set(protonhax.list_running())
|
||||
GLib.idle_add(self._on_running_checked, running_ids)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _on_running_checked(self, running_ids: set[str]) -> None:
|
||||
"""Update running state on main thread."""
|
||||
self.update_running_state(running_ids)
|
||||
|
||||
# Auto-launch CE if needed
|
||||
if self._auto_ce and self._auto_ce in running_ids:
|
||||
app_id = self._auto_ce
|
||||
tile = self._tiles.get(app_id)
|
||||
name = tile.name if tile else app_id
|
||||
self._auto_ce = None
|
||||
GLib.timeout_add_seconds(2, lambda: self._do_ce(app_id, name))
|
||||
|
||||
# Schedule next check
|
||||
GLib.timeout_add_seconds(5, self._check_running)
|
||||
|
||||
# ─── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_launch(self, app_id: str, name: str) -> None:
|
||||
"""Launch game via Steam."""
|
||||
try:
|
||||
protonhax.ensure_installed()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
subprocess.Popen(
|
||||
["steam", f"steam://rungameid/{app_id}"],
|
||||
start_new_session=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
self._win.show_toast(_("Launching %s...") % name)
|
||||
self._auto_ce = app_id
|
||||
|
||||
def _on_launch_ce(self, app_id: str, name: str) -> None:
|
||||
"""Launch CE for a game."""
|
||||
self._do_ce(app_id, name)
|
||||
|
||||
def _do_ce(self, app_id: str, name: str) -> None:
|
||||
"""Actually launch Cheat Engine."""
|
||||
if not self._cfg.ce_exists:
|
||||
self._win.show_toast(_("CE not found: %s") % self._cfg.ce_executable)
|
||||
return
|
||||
|
||||
args = None
|
||||
if self._cfg.ce_language != "system":
|
||||
args = ["--LANG", self._cfg.ce_language]
|
||||
|
||||
proc = protonhax.run_in_proton(app_id, str(self._cfg.ce_executable_path), args)
|
||||
if proc:
|
||||
self._win.show_toast(_("CE launched for %s") % name)
|
||||
else:
|
||||
self._win.show_toast(_("Failed to launch CE for %s") % name)
|
||||
|
||||
def _on_toggle(self, app_id: str, active: bool) -> None:
|
||||
"""Handle activation toggle."""
|
||||
tile = self._tiles.get(app_id)
|
||||
if not tile:
|
||||
return
|
||||
|
||||
if active:
|
||||
self._apply_single(app_id, tile.name, "set")
|
||||
else:
|
||||
self._apply_single(app_id, tile.name, "remove")
|
||||
|
||||
def _apply_single(self, app_id: str, name: str, mode: str) -> None:
|
||||
"""Apply launch options for a single game."""
|
||||
if steam.is_steam_running():
|
||||
self._win.show_toast(_("Close Steam to change settings"))
|
||||
# Revert switch
|
||||
tile = self._tiles.get(app_id)
|
||||
if tile:
|
||||
tile.set_active_silent(mode != "set")
|
||||
return
|
||||
|
||||
try:
|
||||
protonhax.ensure_installed()
|
||||
except OSError as e:
|
||||
self._win.show_toast(f"protonhax: {e}")
|
||||
return
|
||||
|
||||
localconfig = steam.find_localconfig_vdf(self._cfg.steam_path)
|
||||
if not localconfig:
|
||||
self._win.show_toast(_("localconfig.vdf not found"))
|
||||
return
|
||||
|
||||
if mode == "set":
|
||||
ok = steam.set_launch_options(app_id, self._cfg.launch_options_template, localconfig)
|
||||
verb = _("set")
|
||||
else:
|
||||
ok = steam.remove_launch_options(app_id, localconfig)
|
||||
verb = _("removed")
|
||||
|
||||
if ok:
|
||||
self._win.show_toast(_("LaunchOptions %s for %s") % (verb, name))
|
||||
else:
|
||||
self._win.show_toast(_("Error changing settings for %s") % name)
|
||||
# Revert switch
|
||||
tile = self._tiles.get(app_id)
|
||||
if tile:
|
||||
tile.set_active_silent(mode != "set")
|
||||
|
||||
# ─── Batch operations ─────────────────────────────────────────────────────
|
||||
|
||||
def _on_enable_all(self, _btn) -> None:
|
||||
"""Show dialog for enabling all games."""
|
||||
dlg = Adw.AlertDialog(
|
||||
heading=_("Enable for all games?"),
|
||||
body=self._cfg.launch_options_template,
|
||||
)
|
||||
dlg.add_response("cancel", _("Cancel"))
|
||||
dlg.add_response("ok", _("Enable"))
|
||||
dlg.set_response_appearance("ok", Adw.ResponseAppearance.SUGGESTED)
|
||||
dlg.choose(self._win, None, self._finish_enable)
|
||||
|
||||
def _finish_enable(self, dlg, res) -> None:
|
||||
if dlg.choose_finish(res) != "ok":
|
||||
return
|
||||
try:
|
||||
protonhax.ensure_installed()
|
||||
except OSError as e:
|
||||
self._win.show_toast(f"protonhax: {e}")
|
||||
return
|
||||
ids = [aid for aid, t in self._tiles.items() if not t.is_active]
|
||||
if ids:
|
||||
self._apply_batch(ids, "set")
|
||||
|
||||
def _on_disable_all(self, _btn) -> None:
|
||||
"""Show dialog for disabling all games."""
|
||||
dlg = Adw.AlertDialog(
|
||||
heading=_("Disable for all games?"),
|
||||
body=_("LaunchOptions will be removed."),
|
||||
)
|
||||
dlg.add_response("cancel", _("Cancel"))
|
||||
dlg.add_response("ok", _("Disable"))
|
||||
dlg.set_response_appearance("ok", Adw.ResponseAppearance.DESTRUCTIVE)
|
||||
dlg.choose(self._win, None, self._finish_disable)
|
||||
|
||||
def _finish_disable(self, dlg, res) -> None:
|
||||
if dlg.choose_finish(res) != "ok":
|
||||
return
|
||||
ids = [aid for aid, t in self._tiles.items() if t.is_active]
|
||||
if ids:
|
||||
self._apply_batch(ids, "remove")
|
||||
|
||||
def _apply_batch(self, ids: list[str], mode: str) -> None:
|
||||
"""Apply changes to multiple games."""
|
||||
if steam.is_steam_running():
|
||||
self._win.show_toast(_("Restarting Steam to apply..."))
|
||||
|
||||
def wait_and_apply():
|
||||
steam.shutdown_steam()
|
||||
if not steam.wait_steam_exit(30):
|
||||
GLib.idle_add(self._win.show_toast, _("Steam did not exit"))
|
||||
return
|
||||
GLib.idle_add(self._write_batch, ids, mode, True)
|
||||
|
||||
threading.Thread(target=wait_and_apply, daemon=True).start()
|
||||
else:
|
||||
self._write_batch(ids, mode, False)
|
||||
|
||||
def _write_batch(self, ids: list[str], mode: str, restart: bool) -> None:
|
||||
"""Write launch options for multiple games."""
|
||||
localconfig = steam.find_localconfig_vdf(self._cfg.steam_path)
|
||||
if not localconfig:
|
||||
self._win.show_toast(_("localconfig.vdf not found"))
|
||||
return
|
||||
|
||||
ok = 0
|
||||
for app_id in ids:
|
||||
if mode == "set":
|
||||
result = steam.set_launch_options(app_id, self._cfg.launch_options_template, localconfig)
|
||||
else:
|
||||
result = steam.remove_launch_options(app_id, localconfig)
|
||||
|
||||
if result:
|
||||
ok += 1
|
||||
tile = self._tiles.get(app_id)
|
||||
if tile:
|
||||
tile.set_active_silent(mode == "set")
|
||||
|
||||
if mode == "set":
|
||||
self._win.show_toast(_("LaunchOptions set for %d games") % ok)
|
||||
else:
|
||||
self._win.show_toast(_("LaunchOptions removed for %d games") % ok)
|
||||
|
||||
if restart:
|
||||
steam.start_steam()
|
||||
|
||||
def schedule_auto_launch(self, app_id: str) -> None:
|
||||
"""Schedule auto CE launch for a game."""
|
||||
self._auto_ce = app_id
|
||||
@@ -0,0 +1,783 @@
|
||||
"""Preferences window — clean libadwaita implementation.
|
||||
|
||||
Uses Adw.PreferencesWindow with auto-save on changes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
|
||||
from gi.repository import Adw, Gio, GLib, Gtk
|
||||
|
||||
from vual import cheatengine, protonhax, wine_theme
|
||||
from vual.config import Config
|
||||
from vual.i18n import _
|
||||
|
||||
|
||||
class PreferencesWindow(Adw.PreferencesWindow):
|
||||
"""Preferences window with auto-save."""
|
||||
|
||||
def __init__(self, config: Config, **kwargs) -> None:
|
||||
super().__init__(
|
||||
title=_("Preferences"),
|
||||
**kwargs,
|
||||
)
|
||||
self._config = config
|
||||
self._exclusion_rows: list[Adw.EntryRow] = []
|
||||
self._release_info: dict | None = None
|
||||
self._building = True # Prevent saves during initial build
|
||||
|
||||
self._build_pages()
|
||||
self._refresh_status()
|
||||
self._building = False
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Save config if not building UI."""
|
||||
if self._building:
|
||||
return
|
||||
self._config.save()
|
||||
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
# Pages
|
||||
# ════════════════════════════════════════════════════════════════
|
||||
|
||||
def _build_pages(self) -> None:
|
||||
"""Build all preference pages."""
|
||||
for build in (self._build_appearance_page, self._build_steam_page, self._build_ce_page):
|
||||
page = build()
|
||||
page.connect("map", self._on_page_mapped)
|
||||
self.add(page)
|
||||
|
||||
def _on_page_mapped(self, page: Adw.PreferencesPage) -> None:
|
||||
"""Reset focus when switching pages to avoid auto-selecting EntryRow."""
|
||||
GLib.idle_add(self.set_focus, None)
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Appearance Page
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_appearance_page(self) -> Adw.PreferencesPage:
|
||||
page = Adw.PreferencesPage(
|
||||
title=_("Appearance"),
|
||||
icon_name="preferences-desktop-appearance-symbolic",
|
||||
)
|
||||
|
||||
# Theme group
|
||||
theme_group = Adw.PreferencesGroup(title=_("Theme"))
|
||||
page.add(theme_group)
|
||||
|
||||
self._theme_row = Adw.ComboRow(
|
||||
title=_("Color scheme"),
|
||||
subtitle=_("Light, dark, or system"),
|
||||
)
|
||||
model = Gtk.StringList.new([_("System"), _("Light"), _("Dark")])
|
||||
self._theme_row.set_model(model)
|
||||
idx = {"system": 0, "light": 1, "dark": 2}.get(self._config.color_scheme, 0)
|
||||
self._theme_row.set_selected(idx)
|
||||
self._theme_row.connect("notify::selected", self._on_theme_changed)
|
||||
theme_group.add(self._theme_row)
|
||||
|
||||
# Language selection
|
||||
self._lang_app_row = Adw.ComboRow(
|
||||
title=_("Language"),
|
||||
subtitle=_("Application interface language"),
|
||||
)
|
||||
lang_app_model = Gtk.StringList.new([_("System"), "English", "Русский"])
|
||||
self._lang_app_row.set_model(lang_app_model)
|
||||
lang_app_idx = {"system": 0, "en": 1, "ru": 2}.get(self._config.app_language, 0)
|
||||
self._lang_app_row.set_selected(lang_app_idx)
|
||||
self._lang_app_row.connect("notify::selected", self._on_app_lang_changed)
|
||||
theme_group.add(self._lang_app_row)
|
||||
|
||||
# Grid group
|
||||
grid_group = Adw.PreferencesGroup(
|
||||
title=_("Grid"),
|
||||
description=_("Game display settings"),
|
||||
)
|
||||
page.add(grid_group)
|
||||
|
||||
self._tile_row = Adw.ComboRow(
|
||||
title=_("Tile size"),
|
||||
subtitle=_("Cover size in library"),
|
||||
)
|
||||
tile_model = Gtk.StringList.new([_("Small (120px)"), _("Medium (150px)"), _("Large (180px)")])
|
||||
self._tile_row.set_model(tile_model)
|
||||
tile_idx = {"small": 0, "medium": 1, "large": 2}.get(self._config.tile_size, 1)
|
||||
self._tile_row.set_selected(tile_idx)
|
||||
self._tile_row.connect("notify::selected", self._on_tile_size_changed)
|
||||
grid_group.add(self._tile_row)
|
||||
|
||||
self._sort_row = Adw.ComboRow(
|
||||
title=_("Sort"),
|
||||
subtitle=_("Default game order"),
|
||||
)
|
||||
sort_model = Gtk.StringList.new([_("By name"), _("By status")])
|
||||
self._sort_row.set_model(sort_model)
|
||||
sort_idx = {"name": 0, "status": 1}.get(self._config.sort_by, 0)
|
||||
self._sort_row.set_selected(sort_idx)
|
||||
self._sort_row.connect("notify::selected", self._on_sort_changed)
|
||||
grid_group.add(self._sort_row)
|
||||
|
||||
# Wine theme group
|
||||
wine_group = Adw.PreferencesGroup(
|
||||
title=_("Wine Theme"),
|
||||
description=_("Color scheme for Cheat Engine and other Wine apps"),
|
||||
)
|
||||
page.add(wine_group)
|
||||
|
||||
self._wine_theme_row = Adw.ComboRow(
|
||||
title=_("Color scheme"),
|
||||
subtitle=_("Applied to all Proton prefixes"),
|
||||
)
|
||||
wine_model = Gtk.StringList.new([_("System"), _("Dark"), _("Light")])
|
||||
self._wine_theme_row.set_model(wine_model)
|
||||
wine_idx = {"system": 0, "dark": 1, "light": 2}.get(self._config.wine_theme, 0)
|
||||
self._wine_theme_row.set_selected(wine_idx)
|
||||
self._wine_theme_row.connect("notify::selected", self._on_wine_theme_changed)
|
||||
|
||||
# Refresh button
|
||||
self._wine_refresh_btn = Gtk.Button(
|
||||
icon_name="view-refresh-symbolic",
|
||||
valign=Gtk.Align.CENTER,
|
||||
css_classes=["flat"],
|
||||
tooltip_text=_("Reapply theme"),
|
||||
)
|
||||
self._wine_refresh_btn.connect("clicked", self._on_wine_theme_refresh)
|
||||
self._wine_theme_row.add_suffix(self._wine_refresh_btn)
|
||||
|
||||
wine_group.add(self._wine_theme_row)
|
||||
|
||||
return page
|
||||
|
||||
def _on_theme_changed(self, row: Adw.ComboRow, _pspec) -> None:
|
||||
schemes = ["system", "light", "dark"]
|
||||
self._config.color_scheme = schemes[row.get_selected()]
|
||||
self._save()
|
||||
self._apply_theme()
|
||||
|
||||
def _on_tile_size_changed(self, row: Adw.ComboRow, _pspec) -> None:
|
||||
sizes = ["small", "medium", "large"]
|
||||
self._config.tile_size = sizes[row.get_selected()]
|
||||
self._save()
|
||||
|
||||
def _on_sort_changed(self, row: Adw.ComboRow, _pspec) -> None:
|
||||
sorts = ["name", "status"]
|
||||
self._config.sort_by = sorts[row.get_selected()]
|
||||
self._save()
|
||||
|
||||
def _on_app_lang_changed(self, row: Adw.ComboRow, _pspec) -> None:
|
||||
langs = ["system", "en", "ru"]
|
||||
lang = langs[row.get_selected()]
|
||||
self._config.app_language = lang
|
||||
self._save()
|
||||
# Show restart hint
|
||||
self._show_toast(_("Restart app to apply language"))
|
||||
|
||||
def _apply_theme(self) -> None:
|
||||
style = Adw.StyleManager.get_default()
|
||||
schemes = {
|
||||
"light": Adw.ColorScheme.FORCE_LIGHT,
|
||||
"dark": Adw.ColorScheme.FORCE_DARK,
|
||||
"system": Adw.ColorScheme.DEFAULT,
|
||||
}
|
||||
style.set_color_scheme(schemes.get(self._config.color_scheme, Adw.ColorScheme.DEFAULT))
|
||||
|
||||
def _get_system_theme(self) -> str:
|
||||
"""Detect system color scheme. Returns 'dark' or 'light'."""
|
||||
style = Adw.StyleManager.get_default()
|
||||
return "dark" if style.get_dark() else "light"
|
||||
|
||||
def _on_wine_theme_changed(self, row: Adw.ComboRow, _pspec) -> None:
|
||||
"""Handle Wine theme change — save and apply immediately."""
|
||||
themes = ["system", "dark", "light"]
|
||||
theme = themes[row.get_selected()]
|
||||
self._config.wine_theme = theme
|
||||
self._save()
|
||||
|
||||
# For "system", detect actual system theme
|
||||
actual_theme = self._get_system_theme() if theme == "system" else theme
|
||||
|
||||
# Apply in background
|
||||
row.set_sensitive(False)
|
||||
self._wine_refresh_btn.set_sensitive(False)
|
||||
|
||||
def worker() -> None:
|
||||
steamapps = self._config.steamapps_path
|
||||
success, failed = wine_theme.apply_theme_to_all(steamapps, actual_theme)
|
||||
GLib.idle_add(self._on_wine_theme_applied, row, success, failed)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _on_wine_theme_applied(self, row: Adw.ComboRow, success: int, failed: int) -> None:
|
||||
"""Handle Wine theme application result."""
|
||||
row.set_sensitive(True)
|
||||
self._wine_refresh_btn.set_sensitive(True)
|
||||
|
||||
if success > 0:
|
||||
self._show_toast(_("Theme applied to %d prefixes") % success)
|
||||
elif failed > 0:
|
||||
self._show_toast(_("Error applying to %d prefixes") % failed)
|
||||
|
||||
def _on_wine_theme_refresh(self, _btn: Gtk.Button) -> None:
|
||||
"""Refresh/reapply Wine theme to all prefixes."""
|
||||
theme = self._config.wine_theme
|
||||
actual_theme = self._get_system_theme() if theme == "system" else theme
|
||||
|
||||
self._wine_theme_row.set_sensitive(False)
|
||||
self._wine_refresh_btn.set_sensitive(False)
|
||||
|
||||
def worker() -> None:
|
||||
steamapps = self._config.steamapps_path
|
||||
success, failed = wine_theme.apply_theme_to_all(steamapps, actual_theme)
|
||||
GLib.idle_add(self._on_wine_theme_applied, self._wine_theme_row, success, failed)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _show_toast(self, message: str) -> None:
|
||||
"""Show a toast notification."""
|
||||
toast = Adw.Toast(title=message, timeout=3)
|
||||
self.add_toast(toast)
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Steam Page
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_steam_page(self) -> Adw.PreferencesPage:
|
||||
page = Adw.PreferencesPage(
|
||||
title="Steam",
|
||||
icon_name="folder-games-symbolic",
|
||||
)
|
||||
|
||||
# Paths group
|
||||
paths_group = Adw.PreferencesGroup(title=_("Paths"))
|
||||
page.add(paths_group)
|
||||
|
||||
self._steam_row = Adw.EntryRow(title=_("Steam directory"))
|
||||
self._steam_row.set_text(self._config.steam_path)
|
||||
self._steam_row.connect("changed", self._on_steam_path_changed)
|
||||
|
||||
steam_browse = Gtk.Button(
|
||||
icon_name="folder-open-symbolic",
|
||||
valign=Gtk.Align.CENTER,
|
||||
css_classes=["flat"],
|
||||
tooltip_text=_("Choose folder"),
|
||||
)
|
||||
steam_browse.connect("clicked", self._on_browse_steam)
|
||||
self._steam_row.add_suffix(steam_browse)
|
||||
paths_group.add(self._steam_row)
|
||||
|
||||
# Launch options group
|
||||
launch_group = Adw.PreferencesGroup(
|
||||
title=_("Launch options"),
|
||||
description=_("LaunchOptions template for protonhax"),
|
||||
)
|
||||
page.add(launch_group)
|
||||
|
||||
self._template_row = Adw.EntryRow(title=_("Template"))
|
||||
self._template_row.set_text(self._config.launch_options_template)
|
||||
self._template_row.connect("changed", self._on_template_changed)
|
||||
launch_group.add(self._template_row)
|
||||
|
||||
hint_row = Adw.ActionRow(
|
||||
title="%COMMAND%",
|
||||
subtitle=_("Original launch command is substituted"),
|
||||
)
|
||||
hint_row.set_activatable(False)
|
||||
hint_row.add_css_class("dim-label")
|
||||
launch_group.add(hint_row)
|
||||
|
||||
# Exclusions group
|
||||
excl_group = Adw.PreferencesGroup(
|
||||
title=_("Exclusions"),
|
||||
description=_("Regex patterns to hide apps"),
|
||||
)
|
||||
page.add(excl_group)
|
||||
self._excl_group = excl_group
|
||||
|
||||
for pattern in self._config.excluded_app_patterns:
|
||||
self._add_exclusion_row(pattern)
|
||||
|
||||
add_row = Adw.ActionRow(title=_("Add pattern"))
|
||||
add_btn = Gtk.Button(
|
||||
icon_name="list-add-symbolic",
|
||||
valign=Gtk.Align.CENTER,
|
||||
css_classes=["flat"],
|
||||
)
|
||||
add_btn.connect("clicked", self._on_add_exclusion)
|
||||
add_row.add_suffix(add_btn)
|
||||
add_row.set_activatable_widget(add_btn)
|
||||
excl_group.add(add_row)
|
||||
self._add_excl_row = add_row
|
||||
|
||||
return page
|
||||
|
||||
def _on_steam_path_changed(self, row: Adw.EntryRow) -> None:
|
||||
self._config.steam_path = row.get_text().strip()
|
||||
self._save()
|
||||
|
||||
def _on_template_changed(self, row: Adw.EntryRow) -> None:
|
||||
self._config.launch_options_template = row.get_text().strip()
|
||||
self._save()
|
||||
|
||||
def _on_browse_steam(self, _btn: Gtk.Button) -> None:
|
||||
dialog = Gtk.FileDialog(title=_("Select Steam directory"))
|
||||
dialog.select_folder(self, None, self._on_steam_folder_selected)
|
||||
|
||||
def _on_steam_folder_selected(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
|
||||
try:
|
||||
folder = dialog.select_folder_finish(result)
|
||||
if folder:
|
||||
path = folder.get_path()
|
||||
home = str(Path.home())
|
||||
display = path.replace(home, "~", 1) if path.startswith(home) else path
|
||||
self._steam_row.set_text(display)
|
||||
except GLib.Error:
|
||||
pass
|
||||
|
||||
def _add_exclusion_row(self, text: str = "") -> Adw.EntryRow:
|
||||
row = Adw.EntryRow(title="Regex")
|
||||
row.set_text(text)
|
||||
row.connect("changed", self._on_exclusion_changed)
|
||||
|
||||
remove_btn = Gtk.Button(
|
||||
icon_name="user-trash-symbolic",
|
||||
valign=Gtk.Align.CENTER,
|
||||
css_classes=["flat", "error"],
|
||||
tooltip_text=_("Remove"),
|
||||
)
|
||||
remove_btn.connect("clicked", self._on_remove_exclusion, row)
|
||||
row.add_suffix(remove_btn)
|
||||
|
||||
self._exclusion_rows.append(row)
|
||||
self._excl_group.add(row)
|
||||
return row
|
||||
|
||||
def _on_add_exclusion(self, _btn: Gtk.Button) -> None:
|
||||
row = self._add_exclusion_row()
|
||||
row.grab_focus()
|
||||
|
||||
def _on_remove_exclusion(self, _btn: Gtk.Button, row: Adw.EntryRow) -> None:
|
||||
if row in self._exclusion_rows:
|
||||
self._exclusion_rows.remove(row)
|
||||
self._excl_group.remove(row)
|
||||
self._sync_exclusions()
|
||||
|
||||
def _on_exclusion_changed(self, _row: Adw.EntryRow) -> None:
|
||||
self._sync_exclusions()
|
||||
|
||||
def _sync_exclusions(self) -> None:
|
||||
self._config.excluded_app_patterns = [
|
||||
row.get_text().strip()
|
||||
for row in self._exclusion_rows
|
||||
if row.get_text().strip()
|
||||
]
|
||||
self._save()
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Cheat Engine Page
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_ce_page(self) -> Adw.PreferencesPage:
|
||||
page = Adw.PreferencesPage(
|
||||
title="Cheat Engine",
|
||||
icon_name="applications-games-symbolic",
|
||||
)
|
||||
|
||||
# Status group
|
||||
status_group = Adw.PreferencesGroup(title=_("Status"))
|
||||
page.add(status_group)
|
||||
|
||||
self._ce_status_row = Adw.ActionRow(
|
||||
title="Cheat Engine",
|
||||
subtitle=_("Checking..."),
|
||||
)
|
||||
self._ce_status_row.set_activatable(False)
|
||||
status_group.add(self._ce_status_row)
|
||||
|
||||
self._ph_status_row = Adw.ActionRow(
|
||||
title="protonhax",
|
||||
subtitle=_("Checking..."),
|
||||
)
|
||||
self._ph_status_row.set_activatable(False)
|
||||
status_group.add(self._ph_status_row)
|
||||
|
||||
# Installation group
|
||||
install_group = Adw.PreferencesGroup(
|
||||
title=_("Installation"),
|
||||
description=_("Cheat Engine setup and download"),
|
||||
)
|
||||
page.add(install_group)
|
||||
|
||||
self._ce_path_row = Adw.EntryRow(title=_("Executable"))
|
||||
self._ce_path_row.set_text(self._config.ce_executable)
|
||||
self._ce_path_row.connect("changed", self._on_ce_path_changed)
|
||||
|
||||
browse_btn = Gtk.Button(
|
||||
icon_name="document-open-symbolic",
|
||||
valign=Gtk.Align.CENTER,
|
||||
css_classes=["flat"],
|
||||
tooltip_text=_("Choose file"),
|
||||
)
|
||||
browse_btn.connect("clicked", self._on_browse_ce)
|
||||
self._ce_path_row.add_suffix(browse_btn)
|
||||
install_group.add(self._ce_path_row)
|
||||
|
||||
self._dl_row = Adw.ActionRow(
|
||||
title=_("Latest version"),
|
||||
subtitle=_("Click Check"),
|
||||
)
|
||||
self._check_btn = Gtk.Button(
|
||||
label=_("Check"),
|
||||
valign=Gtk.Align.CENTER,
|
||||
)
|
||||
self._check_btn.connect("clicked", self._on_check_release)
|
||||
self._dl_row.add_suffix(self._check_btn)
|
||||
install_group.add(self._dl_row)
|
||||
|
||||
self._progress_row = Adw.ActionRow(
|
||||
title=_("Download"),
|
||||
subtitle="",
|
||||
visible=False,
|
||||
)
|
||||
self._progress = Gtk.ProgressBar(
|
||||
valign=Gtk.Align.CENTER,
|
||||
show_text=True,
|
||||
)
|
||||
self._progress.set_size_request(150, -1)
|
||||
self._progress_row.add_suffix(self._progress)
|
||||
install_group.add(self._progress_row)
|
||||
|
||||
# protonhax group
|
||||
ph_group = Adw.PreferencesGroup(
|
||||
title="protonhax",
|
||||
description=_("Required for attaching to Proton games"),
|
||||
)
|
||||
page.add(ph_group)
|
||||
|
||||
ph_row = Adw.ActionRow(
|
||||
title=_("Install or update"),
|
||||
subtitle=_("Downloads latest version from GitHub"),
|
||||
)
|
||||
self._ph_btn = Gtk.Button(
|
||||
label=_("Install"),
|
||||
valign=Gtk.Align.CENTER,
|
||||
)
|
||||
self._ph_btn.connect("clicked", self._on_install_protonhax)
|
||||
ph_row.add_suffix(self._ph_btn)
|
||||
ph_group.add(ph_row)
|
||||
|
||||
# Language group
|
||||
lang_group = Adw.PreferencesGroup(
|
||||
title=_("Language"),
|
||||
description=_("Cheat Engine interface language"),
|
||||
)
|
||||
page.add(lang_group)
|
||||
|
||||
self._lang_row = Adw.ComboRow(
|
||||
title=_("Interface"),
|
||||
subtitle=_("Requires CE restart"),
|
||||
)
|
||||
lang_model = Gtk.StringList.new([_("System"), _("Russian")])
|
||||
self._lang_row.set_model(lang_model)
|
||||
lang_idx = {"system": 0, "ru_RU": 1}.get(self._config.ce_language, 0)
|
||||
self._lang_row.set_selected(lang_idx)
|
||||
self._lang_row.connect("notify::selected", self._on_lang_changed)
|
||||
lang_group.add(self._lang_row)
|
||||
|
||||
self._loc_row = Adw.ActionRow(
|
||||
title=_("Russian localization"),
|
||||
subtitle=_("Checking..."),
|
||||
)
|
||||
self._loc_btn = Gtk.Button(
|
||||
label=_("Install"),
|
||||
valign=Gtk.Align.CENTER,
|
||||
)
|
||||
self._loc_btn.connect("clicked", self._on_install_localization)
|
||||
self._loc_row.add_suffix(self._loc_btn)
|
||||
lang_group.add(self._loc_row)
|
||||
|
||||
# Debug group
|
||||
debug_group = Adw.PreferencesGroup(title=_("Debug"))
|
||||
page.add(debug_group)
|
||||
|
||||
test_row = Adw.ActionRow(
|
||||
title=_("Test launch"),
|
||||
subtitle=_("Launch CE via Wine (without game binding)"),
|
||||
)
|
||||
self._test_btn = Gtk.Button(
|
||||
label=_("Launch"),
|
||||
valign=Gtk.Align.CENTER,
|
||||
css_classes=["suggested-action"],
|
||||
)
|
||||
self._test_btn.connect("clicked", self._on_test_launch)
|
||||
test_row.add_suffix(self._test_btn)
|
||||
debug_group.add(test_row)
|
||||
|
||||
return page
|
||||
|
||||
def _on_ce_path_changed(self, row: Adw.EntryRow) -> None:
|
||||
self._config.ce_executable = row.get_text().strip()
|
||||
self._save()
|
||||
self._refresh_status()
|
||||
|
||||
def _on_browse_ce(self, _btn: Gtk.Button) -> None:
|
||||
dialog = Gtk.FileDialog(title=_("Select CE executable"))
|
||||
|
||||
filters = Gio.ListStore.new(Gtk.FileFilter)
|
||||
exe_filter = Gtk.FileFilter()
|
||||
exe_filter.set_name(_("Executable files (*.exe)"))
|
||||
exe_filter.add_pattern("*.exe")
|
||||
filters.append(exe_filter)
|
||||
|
||||
all_filter = Gtk.FileFilter()
|
||||
all_filter.set_name(_("All files"))
|
||||
all_filter.add_pattern("*")
|
||||
filters.append(all_filter)
|
||||
|
||||
dialog.set_filters(filters)
|
||||
dialog.open(self, None, self._on_ce_file_selected)
|
||||
|
||||
def _on_ce_file_selected(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
|
||||
try:
|
||||
gfile = dialog.open_finish(result)
|
||||
if gfile:
|
||||
path = gfile.get_path()
|
||||
home = str(Path.home())
|
||||
display = path.replace(home, "~", 1) if path.startswith(home) else path
|
||||
self._ce_path_row.set_text(display)
|
||||
except GLib.Error:
|
||||
pass
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Status & Actions
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _refresh_status(self) -> None:
|
||||
"""Update CE and protonhax status."""
|
||||
# CE status
|
||||
if self._config.ce_exists:
|
||||
version = cheatengine.detect_version(self._config.ce_executable_path)
|
||||
ver_str = f" ({version})" if version else ""
|
||||
self._ce_status_row.set_subtitle(_("✓ Installed") + ver_str)
|
||||
self._test_btn.set_sensitive(True)
|
||||
else:
|
||||
self._ce_status_row.set_subtitle(_("✗ Not found"))
|
||||
self._test_btn.set_sensitive(False)
|
||||
|
||||
# protonhax status
|
||||
ph_path = protonhax.find_installed()
|
||||
if ph_path:
|
||||
if protonhax.is_managed():
|
||||
if protonhax.needs_update():
|
||||
self._ph_status_row.set_subtitle(_("⟳ Update available"))
|
||||
self._ph_btn.set_label(_("Refresh"))
|
||||
else:
|
||||
self._ph_status_row.set_subtitle(_("✓ Installed"))
|
||||
self._ph_btn.set_label(_("Reinstall"))
|
||||
else:
|
||||
self._ph_status_row.set_subtitle(_("✓ External: %s") % ph_path)
|
||||
else:
|
||||
self._ph_status_row.set_subtitle(_("✗ Not installed"))
|
||||
self._ph_btn.set_label(_("Install"))
|
||||
|
||||
# Localization status
|
||||
if self._config.ce_exists:
|
||||
if cheatengine.is_localization_installed(self._config.ce_executable_path):
|
||||
self._loc_row.set_subtitle(_("✓ Installed"))
|
||||
self._loc_btn.set_label(_("Reinstall"))
|
||||
else:
|
||||
self._loc_row.set_subtitle(_("Not installed"))
|
||||
self._loc_btn.set_label(_("Install"))
|
||||
self._loc_btn.set_sensitive(True)
|
||||
else:
|
||||
self._loc_row.set_subtitle(_("Cheat Engine required"))
|
||||
self._loc_btn.set_sensitive(False)
|
||||
|
||||
def _on_test_launch(self, _btn: Gtk.Button) -> None:
|
||||
if not self._config.ce_exists:
|
||||
self.add_toast(Adw.Toast(title=_("Cheat Engine not found")))
|
||||
return
|
||||
|
||||
wine = cheatengine.find_proton_wine(self._config.steam_path)
|
||||
if not wine:
|
||||
self.add_toast(Adw.Toast(title=_("Wine not found — install Proton")))
|
||||
return
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
env = os.environ.copy()
|
||||
prefix = Path.home() / ".local" / "share" / "vual" / "wine_prefix"
|
||||
prefix.mkdir(parents=True, exist_ok=True)
|
||||
env["WINEPREFIX"] = str(prefix)
|
||||
|
||||
cmd = [str(wine), str(self._config.ce_executable_path)]
|
||||
if self._config.ce_language != "system":
|
||||
cmd.extend(["--LANG", self._config.ce_language])
|
||||
|
||||
try:
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
self.add_toast(Adw.Toast(title=_("Cheat Engine launched")))
|
||||
except OSError as e:
|
||||
self.add_toast(Adw.Toast(title=_("Error: %s") % e))
|
||||
|
||||
def _on_install_protonhax(self, _btn: Gtk.Button) -> None:
|
||||
try:
|
||||
protonhax.ensure_installed()
|
||||
self._refresh_status()
|
||||
self.add_toast(Adw.Toast(title=_("protonhax installed")))
|
||||
except OSError as e:
|
||||
self.add_toast(Adw.Toast(title=_("Error: %s") % e))
|
||||
|
||||
def _on_lang_changed(self, row: Adw.ComboRow, _pspec) -> None:
|
||||
langs = ["system", "ru_RU"]
|
||||
self._config.ce_language = langs[row.get_selected()]
|
||||
self._save()
|
||||
|
||||
def _on_install_localization(self, btn: Gtk.Button) -> None:
|
||||
if not self._config.ce_exists:
|
||||
self.add_toast(Adw.Toast(title=_("Cheat Engine not found")))
|
||||
return
|
||||
|
||||
btn.set_sensitive(False)
|
||||
self._loc_row.set_subtitle(_("Downloading..."))
|
||||
|
||||
def worker() -> None:
|
||||
ok = cheatengine.install_localization(self._config.ce_executable_path)
|
||||
GLib.idle_add(self._on_localization_done, ok, btn)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _on_localization_done(self, success: bool, btn: Gtk.Button) -> None:
|
||||
btn.set_sensitive(True)
|
||||
self._refresh_status()
|
||||
if success:
|
||||
self.add_toast(Adw.Toast(title=_("Localization installed")))
|
||||
else:
|
||||
self.add_toast(Adw.Toast(title=_("Failed to install localization")))
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# CE Download
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_check_release(self, _btn: Gtk.Button) -> None:
|
||||
self._dl_row.set_subtitle(_("Checking..."))
|
||||
self._check_btn.set_sensitive(False)
|
||||
|
||||
def worker() -> None:
|
||||
info = cheatengine.get_latest_release()
|
||||
GLib.idle_add(self._on_release_checked, info)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _on_release_checked(self, info: dict | None) -> None:
|
||||
self._check_btn.set_sensitive(True)
|
||||
|
||||
if not info or not info.get("url"):
|
||||
self._dl_row.set_subtitle(_("Failed to get info"))
|
||||
return
|
||||
|
||||
self._release_info = info
|
||||
version = info.get("version", "?")
|
||||
size_mb = info.get("size", 0) / 1_048_576
|
||||
|
||||
subtitle = f"v{version}"
|
||||
if size_mb > 0:
|
||||
subtitle += f" ({size_mb:.1f} " + _("MB") + ")"
|
||||
self._dl_row.set_subtitle(subtitle)
|
||||
|
||||
# Replace check button with download button
|
||||
self._dl_row.remove(self._check_btn)
|
||||
dl_btn = Gtk.Button(
|
||||
label=_("Download"),
|
||||
valign=Gtk.Align.CENTER,
|
||||
css_classes=["suggested-action"],
|
||||
)
|
||||
dl_btn.connect("clicked", self._on_download_ce)
|
||||
self._dl_row.add_suffix(dl_btn)
|
||||
self._download_btn = dl_btn
|
||||
|
||||
def _on_download_ce(self, btn: Gtk.Button) -> None:
|
||||
if not self._release_info:
|
||||
return
|
||||
|
||||
url = self._release_info["url"]
|
||||
name = self._release_info.get("name", "CheatEngine.exe")
|
||||
|
||||
cache_dir = Path.home() / ".cache" / "vual"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
installer = cache_dir / name
|
||||
|
||||
dest_dir = Path.home() / ".local" / "share" / "vual" / "cheatengine"
|
||||
|
||||
self._progress_row.set_visible(True)
|
||||
self._progress_row.set_subtitle(_("Downloading: %s") % name)
|
||||
self._progress.set_fraction(0)
|
||||
btn.set_sensitive(False)
|
||||
|
||||
def update_progress(frac: float) -> None:
|
||||
GLib.idle_add(self._progress.set_fraction, frac)
|
||||
|
||||
def worker() -> None:
|
||||
ok = cheatengine.download_file(url, installer, progress_cb=update_progress)
|
||||
if not ok:
|
||||
GLib.idle_add(self._download_failed, btn)
|
||||
return
|
||||
GLib.idle_add(self._start_extraction, installer, dest_dir, btn)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _download_failed(self, btn: Gtk.Button) -> None:
|
||||
self._progress_row.set_visible(False)
|
||||
btn.set_sensitive(True)
|
||||
self.add_toast(Adw.Toast(title=_("Download failed")))
|
||||
|
||||
def _start_extraction(self, installer: Path, dest_dir: Path, btn: Gtk.Button) -> None:
|
||||
self._progress_row.set_subtitle(_("Extracting..."))
|
||||
self._progress.set_fraction(0.5)
|
||||
|
||||
def worker() -> None:
|
||||
extracted = cheatengine.extract_installer(
|
||||
installer, dest_dir, self._config.steam_path
|
||||
)
|
||||
exe = cheatengine.find_executable(dest_dir) if extracted else None
|
||||
GLib.idle_add(self._extraction_done, exe, extracted, installer, btn)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _extraction_done(
|
||||
self,
|
||||
exe_path: Path | None,
|
||||
extracted: bool,
|
||||
installer: Path,
|
||||
btn: Gtk.Button,
|
||||
) -> None:
|
||||
self._progress_row.set_visible(False)
|
||||
btn.set_sensitive(True)
|
||||
|
||||
if exe_path:
|
||||
home = str(Path.home())
|
||||
display = str(exe_path).replace(home, "~", 1)
|
||||
self._config.ce_executable = display
|
||||
self._ce_path_row.set_text(display)
|
||||
self._save()
|
||||
self._refresh_status()
|
||||
installer.unlink(missing_ok=True)
|
||||
self.add_toast(Adw.Toast(title=_("Cheat Engine installed")))
|
||||
elif extracted:
|
||||
self._refresh_status()
|
||||
self.add_toast(Adw.Toast(title=_("Extracted — specify path manually")))
|
||||
else:
|
||||
self.add_toast(Adw.Toast(title=_("Extraction failed")))
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Main application window — single-page tile grid."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
|
||||
from gi.repository import Adw, Gio, GLib, Gtk # noqa: E402
|
||||
|
||||
from vual import APP_NAME # noqa: E402
|
||||
from vual.config import Config # noqa: E402
|
||||
from vual.i18n import _ # noqa: E402
|
||||
|
||||
|
||||
class VualWindow(Adw.ApplicationWindow):
|
||||
def __init__(self, config: Config, **kwargs) -> None:
|
||||
super().__init__(
|
||||
default_width=config.window_width,
|
||||
default_height=config.window_height,
|
||||
title=APP_NAME,
|
||||
**kwargs,
|
||||
)
|
||||
self.config = config
|
||||
self._build_ui()
|
||||
|
||||
# ── UI construction ──────────────────────────────────────────
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self.toast_overlay = Adw.ToastOverlay()
|
||||
self.set_content(self.toast_overlay)
|
||||
|
||||
toolbar_view = Adw.ToolbarView()
|
||||
self.toast_overlay.set_child(toolbar_view)
|
||||
|
||||
# Single main page
|
||||
from vual.ui.main_page import MainPage
|
||||
|
||||
self.main_page = MainPage(config=self.config, window=self)
|
||||
|
||||
# Header bar
|
||||
header = Adw.HeaderBar()
|
||||
header.add_css_class("flat")
|
||||
|
||||
# Title widget: search + counter
|
||||
title_box = Gtk.Box(spacing=8)
|
||||
title_box.append(self.main_page._search)
|
||||
self._counter = Gtk.Label(css_classes=["games-counter"])
|
||||
title_box.append(self._counter)
|
||||
header.set_title_widget(title_box)
|
||||
|
||||
b_refresh = Gtk.Button(
|
||||
icon_name="view-refresh-symbolic",
|
||||
css_classes=["flat", "header-btn"],
|
||||
tooltip_text=_("Refresh"),
|
||||
)
|
||||
b_refresh.connect("clicked", lambda _: self.main_page._load())
|
||||
header.pack_start(b_refresh)
|
||||
|
||||
b_on = Gtk.Button(
|
||||
icon_name="object-select-symbolic",
|
||||
css_classes=["flat", "header-btn"],
|
||||
tooltip_text=_("Enable All"),
|
||||
)
|
||||
b_on.connect("clicked", self.main_page._on_enable_all)
|
||||
header.pack_start(b_on)
|
||||
|
||||
b_off = Gtk.Button(
|
||||
icon_name="edit-clear-all-symbolic",
|
||||
css_classes=["flat", "header-btn"],
|
||||
tooltip_text=_("Disable All"),
|
||||
)
|
||||
b_off.connect("clicked", self.main_page._on_disable_all)
|
||||
header.pack_start(b_off)
|
||||
|
||||
# Sort menu
|
||||
sort_menu = Gio.Menu()
|
||||
sort_menu.append(_("By name"), "win.sort::name")
|
||||
sort_menu.append(_("By status"), "win.sort::status")
|
||||
|
||||
sort_btn = Gtk.MenuButton(
|
||||
icon_name="view-sort-descending-symbolic",
|
||||
css_classes=["flat", "header-btn"],
|
||||
menu_model=sort_menu,
|
||||
tooltip_text=_("Sort"),
|
||||
)
|
||||
header.pack_end(sort_btn)
|
||||
|
||||
# Sort action
|
||||
sort_action = Gio.SimpleAction.new("sort", GLib.VariantType.new("s"))
|
||||
sort_action.connect("activate", self._on_sort_changed)
|
||||
self.add_action(sort_action)
|
||||
|
||||
menu_button = Gtk.MenuButton(
|
||||
icon_name="open-menu-symbolic",
|
||||
css_classes=["flat", "header-btn"],
|
||||
menu_model=self._build_menu(),
|
||||
)
|
||||
header.pack_end(menu_button)
|
||||
toolbar_view.add_top_bar(header)
|
||||
|
||||
toolbar_view.set_content(self.main_page)
|
||||
|
||||
def _on_sort_changed(self, action: Gio.SimpleAction, param: GLib.Variant) -> None:
|
||||
sort_by = param.get_string()
|
||||
self.config.sort_by = sort_by
|
||||
self.main_page._update_sort()
|
||||
|
||||
def update_counter(self, count: int) -> None:
|
||||
"""Update the games counter label."""
|
||||
self._counter.set_label(_("%d games") % count)
|
||||
|
||||
# ── App menu ─────────────────────────────────────────────────
|
||||
|
||||
def _build_menu(self) -> Gio.Menu:
|
||||
menu = Gio.Menu()
|
||||
menu.append(_("Preferences"), "app.preferences")
|
||||
menu.append(_("About"), "app.about")
|
||||
menu.append(_("Quit"), "app.quit")
|
||||
return menu
|
||||
|
||||
# ── Toast helper ─────────────────────────────────────────────
|
||||
|
||||
def show_toast(self, message: str, timeout: int = 3) -> None:
|
||||
toast = Adw.Toast(title=message, timeout=timeout)
|
||||
self.toast_overlay.add_toast(toast)
|
||||
return False
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Wine theme management for Proton prefixes.
|
||||
|
||||
Safely applies dark/light color schemes to Wine prefixes via registry.
|
||||
Only modifies string and dword values — never touches binary/hex data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
# Registry section pattern
|
||||
_SECTION_RE = re.compile(r'^\[([^\]]+)\]', re.MULTILINE)
|
||||
|
||||
# Wine dark color scheme (Control Panel\Colors)
|
||||
# Format: "R G B" strings
|
||||
DARK_COLORS = {
|
||||
"ActiveBorder": "49 54 59",
|
||||
"ActiveTitle": "49 54 59",
|
||||
"AppWorkSpace": "30 30 30",
|
||||
"Background": "30 30 30",
|
||||
"ButtonAlternateFace": "49 54 59",
|
||||
"ButtonDkShadow": "20 20 20",
|
||||
"ButtonFace": "49 54 59",
|
||||
"ButtonHilight": "70 75 80",
|
||||
"ButtonLight": "60 65 70",
|
||||
"ButtonShadow": "35 38 41",
|
||||
"ButtonText": "230 230 230",
|
||||
"GradientActiveTitle": "49 54 59",
|
||||
"GradientInactiveTitle": "40 42 45",
|
||||
"GrayText": "128 128 128",
|
||||
"Hilight": "61 174 233",
|
||||
"HilightText": "255 255 255",
|
||||
"HotTrackingColor": "61 174 233",
|
||||
"InactiveBorder": "40 42 45",
|
||||
"InactiveTitle": "40 42 45",
|
||||
"InactiveTitleText": "128 128 128",
|
||||
"InfoText": "230 230 230",
|
||||
"InfoWindow": "49 54 59",
|
||||
"Menu": "49 54 59",
|
||||
"MenuBar": "49 54 59",
|
||||
"MenuHilight": "61 174 233",
|
||||
"MenuText": "230 230 230",
|
||||
"Scrollbar": "49 54 59",
|
||||
"TitleText": "230 230 230",
|
||||
"Window": "35 38 41",
|
||||
"WindowFrame": "49 54 59",
|
||||
"WindowText": "230 230 230",
|
||||
}
|
||||
|
||||
# Standard light colors (Windows defaults)
|
||||
LIGHT_COLORS = {
|
||||
"ActiveBorder": "180 180 180",
|
||||
"ActiveTitle": "153 180 209",
|
||||
"AppWorkSpace": "171 171 171",
|
||||
"Background": "0 0 0",
|
||||
"ButtonAlternateFace": "0 0 0",
|
||||
"ButtonDkShadow": "105 105 105",
|
||||
"ButtonFace": "240 240 240",
|
||||
"ButtonHilight": "255 255 255",
|
||||
"ButtonLight": "227 227 227",
|
||||
"ButtonShadow": "160 160 160",
|
||||
"ButtonText": "0 0 0",
|
||||
"GradientActiveTitle": "185 209 234",
|
||||
"GradientInactiveTitle": "215 228 242",
|
||||
"GrayText": "109 109 109",
|
||||
"Hilight": "0 120 215",
|
||||
"HilightText": "255 255 255",
|
||||
"HotTrackingColor": "0 102 204",
|
||||
"InactiveBorder": "244 247 252",
|
||||
"InactiveTitle": "191 205 219",
|
||||
"InactiveTitleText": "0 0 0",
|
||||
"InfoText": "0 0 0",
|
||||
"InfoWindow": "255 255 225",
|
||||
"Menu": "240 240 240",
|
||||
"MenuBar": "240 240 240",
|
||||
"MenuHilight": "0 120 215",
|
||||
"MenuText": "0 0 0",
|
||||
"Scrollbar": "200 200 200",
|
||||
"TitleText": "0 0 0",
|
||||
"Window": "255 255 255",
|
||||
"WindowFrame": "100 100 100",
|
||||
"WindowText": "0 0 0",
|
||||
}
|
||||
|
||||
# Color scheme presets
|
||||
COLOR_SCHEMES = {
|
||||
"dark": DARK_COLORS,
|
||||
"light": LIGHT_COLORS,
|
||||
}
|
||||
|
||||
# DWM (Desktop Window Manager) settings for dark theme
|
||||
# AccentColor is ABGR format as dword
|
||||
DARK_DWM = {
|
||||
"AccentColor": 0xff3b3b3b, # Dark gray accent
|
||||
"AccentColorInactive": 0xff2d2d2d, # Darker inactive
|
||||
"ColorizationAfterglow": 0xc43b3b3b,
|
||||
"ColorizationColor": 0xc43b3b3b,
|
||||
"ColorizationColorBalance": 0x59,
|
||||
"ColorizationGlassAttribute": 0x01,
|
||||
"ColorPrevalence": 0x01, # Use accent color on title bars
|
||||
"EnableWindowColorization": 0x01,
|
||||
}
|
||||
|
||||
LIGHT_DWM = {
|
||||
"AccentColor": 0xffd77800, # Blue accent (Windows default)
|
||||
"AccentColorInactive": 0xffdbdbdb,
|
||||
"ColorizationAfterglow": 0xc44f8bcd,
|
||||
"ColorizationColor": 0xc44f8bcd,
|
||||
"ColorizationColorBalance": 0x59,
|
||||
"ColorizationGlassAttribute": 0x01,
|
||||
"ColorPrevalence": 0x00,
|
||||
"EnableWindowColorization": 0x01,
|
||||
}
|
||||
|
||||
DWM_SCHEMES = {
|
||||
"dark": DARK_DWM,
|
||||
"light": LIGHT_DWM,
|
||||
}
|
||||
|
||||
# Explorer accent settings
|
||||
DARK_EXPLORER = {
|
||||
"AccentColorMenu": 0xff3b3b3b,
|
||||
}
|
||||
|
||||
LIGHT_EXPLORER = {
|
||||
"AccentColorMenu": 0xffd77800,
|
||||
}
|
||||
|
||||
EXPLORER_SCHEMES = {
|
||||
"dark": DARK_EXPLORER,
|
||||
"light": LIGHT_EXPLORER,
|
||||
}
|
||||
|
||||
# Vual's own Wine prefix
|
||||
VUAL_PREFIX = Path.home() / ".local" / "share" / "vual" / "wine_prefix"
|
||||
|
||||
|
||||
def get_vual_prefix() -> Path | None:
|
||||
"""Return Vual's Wine prefix if it exists."""
|
||||
if VUAL_PREFIX.is_dir() and (VUAL_PREFIX / "user.reg").is_file():
|
||||
return VUAL_PREFIX
|
||||
return None
|
||||
|
||||
|
||||
def get_all_prefixes(steamapps: Path) -> Iterator[Path]:
|
||||
"""Yield all Proton prefix paths under steamapps/compatdata."""
|
||||
compatdata = steamapps / "compatdata"
|
||||
if not compatdata.is_dir():
|
||||
return
|
||||
|
||||
for entry in compatdata.iterdir():
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
# Skip non-numeric (not app IDs)
|
||||
if not entry.name.isdigit():
|
||||
continue
|
||||
|
||||
pfx = entry / "pfx"
|
||||
if pfx.is_dir():
|
||||
yield pfx
|
||||
|
||||
|
||||
def _find_or_create_section(lines: list[str], section_name: str) -> int:
|
||||
"""Find section index or create it. Returns index of first line after section header.
|
||||
|
||||
Wine registry sections can have timestamps: [Section\\Name] 1234567890
|
||||
We match by prefix to handle this.
|
||||
"""
|
||||
target = f"[{section_name}]"
|
||||
target_lower = target.lower()
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip().lower()
|
||||
# Match exact or with timestamp suffix
|
||||
if stripped == target_lower or stripped.startswith(target_lower + " "):
|
||||
return i + 1
|
||||
|
||||
# Section not found — add at end
|
||||
if lines and lines[-1].strip():
|
||||
lines.append("")
|
||||
lines.append(target)
|
||||
lines.append("")
|
||||
return len(lines) - 1
|
||||
|
||||
|
||||
def _set_string_value(lines: list[str], section_idx: int, key: str, value: str) -> None:
|
||||
"""Set a string value in registry section. Only modifies string values."""
|
||||
# Find section end (next section or EOF)
|
||||
section_end = len(lines)
|
||||
for i in range(section_idx, len(lines)):
|
||||
if lines[i].startswith("["):
|
||||
section_end = i
|
||||
break
|
||||
|
||||
# Look for existing key
|
||||
key_pattern = f'"{key}"='
|
||||
for i in range(section_idx, section_end):
|
||||
if lines[i].startswith(key_pattern):
|
||||
lines[i] = f'"{key}"="{value}"'
|
||||
return
|
||||
|
||||
# Key not found — insert before section end
|
||||
insert_at = section_end
|
||||
# Find last non-empty line in section
|
||||
for i in range(section_end - 1, section_idx - 1, -1):
|
||||
if lines[i].strip():
|
||||
insert_at = i + 1
|
||||
break
|
||||
|
||||
lines.insert(insert_at, f'"{key}"="{value}"')
|
||||
|
||||
|
||||
def _set_dword_value(lines: list[str], section_idx: int, key: str, value: int) -> None:
|
||||
"""Set a dword value in registry section."""
|
||||
# Find section end (next section or EOF)
|
||||
section_end = len(lines)
|
||||
for i in range(section_idx, len(lines)):
|
||||
if lines[i].startswith("["):
|
||||
section_end = i
|
||||
break
|
||||
|
||||
# Format: "Key"=dword:00000000
|
||||
dword_str = f'"{key}"=dword:{value:08x}'
|
||||
|
||||
# Look for existing key
|
||||
key_pattern = f'"{key}"='
|
||||
for i in range(section_idx, section_end):
|
||||
if lines[i].startswith(key_pattern):
|
||||
lines[i] = dword_str
|
||||
return
|
||||
|
||||
# Key not found — insert before section end
|
||||
insert_at = section_end
|
||||
for i in range(section_end - 1, section_idx - 1, -1):
|
||||
if lines[i].strip():
|
||||
insert_at = i + 1
|
||||
break
|
||||
|
||||
lines.insert(insert_at, dword_str)
|
||||
|
||||
|
||||
def apply_colors_to_prefix(pfx: Path, colors: dict[str, str], is_dark: bool) -> bool:
|
||||
"""Apply color scheme to a single prefix.
|
||||
|
||||
Modifies:
|
||||
- [Control Panel\\Colors] — color values as "R G B" strings
|
||||
- [Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize] — dark mode flags
|
||||
|
||||
Args:
|
||||
pfx: Path to prefix directory (containing user.reg).
|
||||
colors: Dict of color name -> "R G B" string values.
|
||||
is_dark: True for dark theme, False for light.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise.
|
||||
"""
|
||||
user_reg = pfx / "user.reg"
|
||||
if not user_reg.is_file():
|
||||
return False
|
||||
|
||||
try:
|
||||
content = user_reg.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
lines = content.splitlines()
|
||||
|
||||
# Find or create Colors section
|
||||
colors_idx = _find_or_create_section(
|
||||
lines,
|
||||
"Control Panel\\\\Colors" # Escaped backslash for .reg format
|
||||
)
|
||||
|
||||
# Apply each color
|
||||
for key, value in colors.items():
|
||||
_set_string_value(lines, colors_idx, key, value)
|
||||
|
||||
# Find or create Personalize section (Windows 10+ dark mode)
|
||||
personalize_idx = _find_or_create_section(
|
||||
lines,
|
||||
"Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Themes\\\\Personalize"
|
||||
)
|
||||
|
||||
# Set dark mode flags: 0 = dark, 1 = light
|
||||
light_value = 0 if is_dark else 1
|
||||
_set_dword_value(lines, personalize_idx, "AppsUseLightTheme", light_value)
|
||||
_set_dword_value(lines, personalize_idx, "SystemUsesLightTheme", light_value)
|
||||
|
||||
# Find or create DWM section for titlebar colors
|
||||
dwm_idx = _find_or_create_section(
|
||||
lines,
|
||||
"Software\\\\Microsoft\\\\Windows\\\\DWM"
|
||||
)
|
||||
|
||||
# Apply DWM settings
|
||||
dwm_settings = DARK_DWM if is_dark else LIGHT_DWM
|
||||
for key, value in dwm_settings.items():
|
||||
_set_dword_value(lines, dwm_idx, key, value)
|
||||
|
||||
# Find or create Explorer Accent section
|
||||
explorer_idx = _find_or_create_section(
|
||||
lines,
|
||||
"Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Explorer\\\\Accent"
|
||||
)
|
||||
|
||||
# Apply Explorer accent settings
|
||||
explorer_settings = DARK_EXPLORER if is_dark else LIGHT_EXPLORER
|
||||
for key, value in explorer_settings.items():
|
||||
_set_dword_value(lines, explorer_idx, key, value)
|
||||
|
||||
# Find ThemeManager section and disable msstyles for dark theme
|
||||
# (Wine visual styles can override our color settings)
|
||||
theme_mgr_idx = _find_or_create_section(
|
||||
lines,
|
||||
"Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\ThemeManager"
|
||||
)
|
||||
|
||||
if is_dark:
|
||||
# Disable visual styles to use our dark colors
|
||||
_set_string_value(lines, theme_mgr_idx, "ThemeActive", "0")
|
||||
else:
|
||||
_set_string_value(lines, theme_mgr_idx, "ThemeActive", "1")
|
||||
|
||||
# Write back
|
||||
try:
|
||||
user_reg.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def apply_theme_to_all(steamapps: Path, theme: str) -> tuple[int, int]:
|
||||
"""Apply theme to all Proton prefixes and Vual's own prefix.
|
||||
|
||||
Args:
|
||||
steamapps: Path to Steam/steamapps directory.
|
||||
theme: Theme name ("dark", "light", or "system").
|
||||
|
||||
Returns:
|
||||
Tuple of (success_count, fail_count).
|
||||
"""
|
||||
if theme == "system" or theme not in COLOR_SCHEMES:
|
||||
return (0, 0)
|
||||
|
||||
colors = COLOR_SCHEMES[theme]
|
||||
is_dark = (theme == "dark")
|
||||
success = 0
|
||||
failed = 0
|
||||
|
||||
# Apply to Steam prefixes
|
||||
for pfx in get_all_prefixes(steamapps):
|
||||
if apply_colors_to_prefix(pfx, colors, is_dark):
|
||||
success += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# Apply to Vual's own prefix
|
||||
vual_pfx = get_vual_prefix()
|
||||
if vual_pfx:
|
||||
if apply_colors_to_prefix(vual_pfx, colors, is_dark):
|
||||
success += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
return (success, failed)
|
||||
|
||||
|
||||
def get_current_theme(pfx: Path) -> str | None:
|
||||
"""Detect current theme from prefix colors.
|
||||
|
||||
Returns:
|
||||
"dark", "light", or None if unknown.
|
||||
"""
|
||||
user_reg = pfx / "user.reg"
|
||||
if not user_reg.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
content = user_reg.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
# Check Window background color
|
||||
match = re.search(r'"Window"="([^"]+)"', content)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
window_color = match.group(1)
|
||||
|
||||
# Dark themes typically have dark Window color
|
||||
parts = window_color.split()
|
||||
if len(parts) == 3:
|
||||
try:
|
||||
r, g, b = int(parts[0]), int(parts[1]), int(parts[2])
|
||||
# If all RGB < 100, it's dark
|
||||
if r < 100 and g < 100 and b < 100:
|
||||
return "dark"
|
||||
# If all RGB > 200, it's light
|
||||
if r > 200 and g > 200 and b > 200:
|
||||
return "light"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dev launcher for Vual."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# All pycache in one place
|
||||
sys.pycache_prefix = str(Path(__file__).resolve().parent / ".pycache")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
|
||||
|
||||
from vual.__main__ import main
|
||||
|
||||
main()
|
||||