This commit is contained in:
2026-03-19 02:47:08 +10:00
commit 66dffe050c
37 changed files with 5572 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+42
View File
@@ -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/
+40
View File
@@ -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.
+99
View File
@@ -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)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+16
View File
@@ -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
+83
View File
@@ -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>
+39
View File
@@ -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()
+457
View File
@@ -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"
+460
View File
@@ -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"
+298
View File
@@ -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 ""
+39
View File
@@ -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
+19
View File
@@ -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
+57
View File
@@ -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()
+99
View File
@@ -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()
+463
View File
@@ -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()
+131
View 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()
+195
View 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;
}
+112
View File
@@ -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()
Binary file not shown.
Binary file not shown.
+235
View File
@@ -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
+538
View File
@@ -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,
)
+1
View File
@@ -0,0 +1 @@
"""Vual UI sub-package."""
+816
View File
@@ -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
+783
View File
@@ -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")))
+128
View File
@@ -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
+406
View File
@@ -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
Executable
+14
View File
@@ -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()