Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
266302d643 | ||
|
|
38d3a43c03 | ||
|
|
c2c5a05013 | ||
|
|
410b24c9b5 | ||
|
|
a525b9f749 | ||
|
|
3c856cd890 | ||
|
|
c2bf75d0da | ||
|
|
50a2498abb | ||
|
|
53ec0f88bd | ||
|
|
b8cebe18b7 | ||
|
|
fe4473ef76 | ||
|
|
7c53112cc9 | ||
|
|
453b5a5a99 | ||
|
|
4990ac941a | ||
|
|
656e20eaa1 | ||
|
|
72ccbaa747 | ||
|
|
e4bafa7a1f | ||
|
|
271fb70d74 | ||
|
|
0884034a31 | ||
|
|
fe37c07729 | ||
|
|
5f506223ac | ||
|
|
0590170cd2 | ||
|
|
ae4eed8be9 | ||
|
|
0231f7fe3f | ||
|
|
e4e7828420 | ||
|
|
41ec383343 | ||
|
|
3591f657f9 | ||
|
|
db88a6ebd0 | ||
|
|
691f13b046 | ||
|
|
b865533a4b | ||
|
|
df22c2b1db | ||
|
|
3bf33340b9 | ||
|
|
1c221ed1cc | ||
|
|
5fa4514665 | ||
|
|
a5e339b112 | ||
|
|
b33f476fc9 | ||
|
|
22d76d4bea | ||
|
|
6a5aeed900 | ||
|
|
b462a4729c | ||
|
|
fb79aeaddb | ||
|
|
47fbff3a0f | ||
|
|
e6051aa04b | ||
|
|
552c118595 | ||
|
|
906ad8cae6 | ||
|
|
cb6255454a | ||
|
|
daf0758d75 | ||
|
|
c8deeb1b8a | ||
|
|
9a8c185846 | ||
|
|
66f11f384d | ||
|
|
e1701fbac9 | ||
|
|
e6717908ac | ||
|
|
68c665fb8e | ||
|
|
d83ef56e0e | ||
|
|
261b70fbe0 | ||
|
|
b76c6f25f5 | ||
|
|
5e22af5b4b | ||
|
|
f84e8a9de3 | ||
|
|
422a22562f | ||
|
|
1caa6e2fc1 | ||
|
|
547a705e37 | ||
|
|
8d69318f11 | ||
|
|
e68636758c | ||
|
|
1f94dfd0e2 | ||
|
|
50bd638ad7 | ||
|
|
b07c35e879 | ||
|
|
0baa84dbed | ||
|
|
f1821b40ff | ||
|
|
608faea597 | ||
|
|
827395a0ed | ||
|
|
3fdfe0fffc | ||
|
|
f9be296cac | ||
|
|
b2e79a3571 | ||
|
|
2d808ae555 | ||
|
|
732c688e3a | ||
|
|
7611dc16c6 | ||
|
|
010f757589 | ||
|
|
55d591ac4f | ||
|
|
95ad8bdedc | ||
|
|
7fe5208333 | ||
|
|
c2e24ff327 | ||
|
|
1b63f2dd0c | ||
|
|
7cf76aaf97 | ||
|
|
b126910229 | ||
|
|
96e29eb528 | ||
|
|
3ec7b756de | ||
|
|
f640aaf6af | ||
|
|
93b9e2f638 | ||
|
|
2030cea983 | ||
|
|
793d960b65 | ||
|
|
ef74003283 | ||
|
|
81a43ca9bd | ||
|
|
f10921840e | ||
|
|
425268ade2 | ||
|
|
743ab69455 | ||
|
|
502c5e4b1b | ||
|
|
44881267cf | ||
|
|
0433ff6cee | ||
|
|
8c984f1b15 | ||
|
|
319c4453fd | ||
|
|
9ec692833e | ||
|
|
1a9297e3e5 | ||
|
|
625ee0a223 | ||
|
|
991a03b829 | ||
|
|
8eba7d21d0 | ||
|
|
eed297a226 | ||
|
|
9017aaac25 | ||
|
|
18f35ed08b | ||
|
|
f5b62e9ec2 | ||
|
|
ae55e69700 | ||
|
|
723cfdcc4c | ||
|
|
fb2f2cf020 | ||
|
|
a7d2ea994e | ||
|
|
572ce2a08a | ||
|
|
d48e50a0b4 | ||
|
|
7321bde951 | ||
|
|
f5e10a8b0d | ||
|
|
61e659ea2e | ||
|
|
728266cdb3 | ||
|
|
055dc04bb1 | ||
|
|
3b5432ecdd | ||
|
|
415ed948ad | ||
|
|
b6e99d01af | ||
|
|
6b53afa238 | ||
|
|
b27d5f31ae | ||
|
|
e62cd1463a | ||
|
|
3bab2d12c7 | ||
|
|
a14ad4ff70 | ||
|
|
39fd02d358 | ||
|
|
d7d0ef4516 | ||
|
|
4b540302dc | ||
|
|
ff7c36208d | ||
|
|
4f3355e633 | ||
|
|
bb637f48f6 | ||
|
|
e76f49a3bd | ||
|
|
0ed10c72f9 | ||
|
|
34849c9490 | ||
|
|
ebabc7ebf7 | ||
|
|
726f2f4b43 | ||
|
|
8a94f519a3 | ||
|
|
468ea73aa6 | ||
|
|
a860cacf2c | ||
|
|
416c4e5336 | ||
|
|
48f694322a | ||
|
|
b58d768ca6 | ||
|
|
28062ff5bb | ||
|
|
4bd3028c4a | ||
|
|
8edceb667c | ||
|
|
45b495eff0 | ||
|
|
0469197d77 | ||
|
|
4f067d6850 | ||
|
|
d0c6bdfdb6 | ||
|
|
41bde9d985 | ||
|
|
ee9bb767a7 |
No files matched your search
@@ -0,0 +1,2 @@
|
||||
seh
|
||||
testser
|
||||
+16
-5
@@ -40,9 +40,20 @@ env/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.tox/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.bak
|
||||
# Test and coverage
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# RPM / package build artifacts
|
||||
*.rpm
|
||||
*.src.rpm
|
||||
RPMS/
|
||||
SRPMS/
|
||||
BUILD/
|
||||
BUILDROOT/
|
||||
*.spec~
|
||||
@@ -0,0 +1,111 @@
|
||||
default_language_version:
|
||||
python: python3
|
||||
|
||||
exclude: |
|
||||
(?x)(
|
||||
^\.git/|
|
||||
^__pycache__/|
|
||||
.*\.pyc$
|
||||
)
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: check-added-large-files
|
||||
args: ["--maxkb=1024"]
|
||||
- id: check-merge-conflict
|
||||
- id: check-symlinks
|
||||
- id: check-executables-have-shebangs
|
||||
- id: check-shebang-scripts-are-executable
|
||||
- id: mixed-line-ending
|
||||
args: ["--fix=lf"]
|
||||
- id: check-case-conflict
|
||||
- id: check-ast
|
||||
- id: debug-statements
|
||||
- id: detect-private-key
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: bash-syntax-check
|
||||
name: bash syntax check
|
||||
entry: bash -n
|
||||
language: system
|
||||
files: |
|
||||
(?x)(
|
||||
^winehelper$|
|
||||
^dependencies\.sh$|
|
||||
^autoinstall/|
|
||||
^manualinstall/|
|
||||
^testinstall/|
|
||||
^hiden_scripts/|
|
||||
^database/.*\.whdb$
|
||||
)
|
||||
types: [shell]
|
||||
|
||||
- id: py-compile
|
||||
name: python compile
|
||||
entry: python3 -m py_compile
|
||||
language: system
|
||||
types: [python]
|
||||
|
||||
- id: shellcheck
|
||||
name: shellcheck
|
||||
entry: shellcheck
|
||||
args: ["--severity=error", "--exclude=SC2068,SC2145,SC2199"]
|
||||
language: system
|
||||
files: |
|
||||
(?x)(
|
||||
^winehelper$|
|
||||
^dependencies\.sh$|
|
||||
^autoinstall/|
|
||||
^manualinstall/|
|
||||
^testinstall/|
|
||||
^hiden_scripts/|
|
||||
^database/.*\.whdb$
|
||||
)
|
||||
types: [shell]
|
||||
|
||||
- repo: https://github.com/scop/pre-commit-shfmt
|
||||
rev: v3.10.0-1
|
||||
hooks:
|
||||
- id: shfmt
|
||||
args: ["--write", "--indent", "4", "--case-indent", "--space-redirects"]
|
||||
files: |
|
||||
(?x)(
|
||||
^winehelper$|
|
||||
^dependencies\.sh$|
|
||||
^autoinstall/|
|
||||
^manualinstall/|
|
||||
^testinstall/|
|
||||
^hiden_scripts/|
|
||||
^database/.*\.whdb$
|
||||
)
|
||||
types: [shell]
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.11.4
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: ["--fix", "--show-fixes"]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.3.0
|
||||
hooks:
|
||||
- id: codespell
|
||||
args: ["--ignore-words", ".codespell-ignorewords"]
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: sha256sum-list-check
|
||||
name: sha256sum.list format check
|
||||
entry: scripts/check_sha256sum_list.sh
|
||||
language: script
|
||||
files: ^sha256sum\.list$
|
||||
pass_filenames: false
|
||||
@@ -15,8 +15,8 @@ WineHelper — инструмент для упрощения установки
|
||||
## Технологический стек
|
||||
|
||||
### Основные технологии
|
||||
- **Bash** — основной язык для CLI-интерфейса (~2692 строк в главном скрипте)
|
||||
- **Python 3** — графический интерфейс с использованием PyQt5
|
||||
- **Bash** — основной язык для CLI-интерфейса (~3075 строк в главном скрипте)
|
||||
- **Python 3** — графический интерфейс с использованием PyQt5 (~3413 строк в `winehelper_gui.py` и модули `winehelperqt/`)
|
||||
- **Wine** — запуск Windows-приложений (поддерживаются различные версии: vanilla, staging, Proton, tkg, crossover)
|
||||
- **DXVK/VKD3D** — трансляция DirectX → Vulkan
|
||||
|
||||
@@ -39,12 +39,16 @@ winehelper/
|
||||
├── winehelper_gui.py # GUI на PyQt5 (точка входа)
|
||||
├── winehelperqt/ # Python-модуль GUI
|
||||
│ ├── __init__.py # Экспорты основных классов
|
||||
│ ├── logger.py # Единый логгер (get_logger/setup_logger), вывод в консоль
|
||||
│ ├── dialogs/ # Диалоговые окна
|
||||
│ │ ├── base_version_dialog.py
|
||||
│ │ ├── component_version.py
|
||||
│ │ ├── create_prefix.py # Создание префикса
|
||||
│ │ ├── file_associations.py
|
||||
│ │ ├── settings.py # Настройки
|
||||
│ │ ├── noexec_warning.py # Предупреждение о noexec
|
||||
│ │ ├── prefix_logging.py # Включение/выключение логирования на вкладке префиксов
|
||||
│ │ ├── settings.py # Настройки префикса
|
||||
│ │ ├── windows_version.py # Выбор версии Windows
|
||||
│ │ ├── wine_version.py # Выбор версии Wine
|
||||
│ │ └── winetricks_manager.py
|
||||
│ ├── parsers/ # Парсеры
|
||||
@@ -52,14 +56,35 @@ winehelper/
|
||||
│ │ ├── sha256_parser.py # Парсинг sha256sum.list
|
||||
│ │ └── winetricks_parser.py
|
||||
│ ├── services/ # Бизнес-логика
|
||||
│ │ ├── application_installer.py
|
||||
│ │ ├── app_launcher.py # Запуск приложений
|
||||
│ │ ├── command_runner.py # Диалоги выполнения команд winehelper (бэкап, удаление и т.п.)
|
||||
│ │ ├── desktop_file_manager.py
|
||||
│ │ ├── install_runner.py # Диалог и процесс установки приложений
|
||||
│ │ ├── prefix_manager.py # Управление префиксами
|
||||
│ │ ├── process_handler.py
|
||||
│ │ ├── script_manager.py
|
||||
│ │ ├── settings_manager.py
|
||||
│ │ └── winetricks_service.py
|
||||
│ ├── utils/ # Утилиты
|
||||
│ │ └── log_handler.py # Обработка логов
|
||||
│ └── widgets/ # Кастомные виджеты
|
||||
│ │ ├── cache_service.py
|
||||
│ │ ├── desktop_entry.py
|
||||
│ │ ├── dialog_utils.py
|
||||
│ │ ├── environment.py
|
||||
│ │ ├── export_utils.py
|
||||
│ │ ├── font_utils.py # Системные шрифты: app_font()/fixed_font()
|
||||
│ │ ├── grid_utils.py
|
||||
│ │ ├── log_handler.py # Обработка логов
|
||||
│ │ ├── prefix_info.py # Построение HTML с информацией о префиксе
|
||||
│ │ ├── process_utils.py
|
||||
│ │ └── version_utils.py
|
||||
│ └── widgets/ # Вкладки главного окна и кастомные виджеты
|
||||
│ ├── help_tab.py # Вкладка «Справка»
|
||||
│ ├── icon_manager.py
|
||||
│ ├── info_panel.py # Контроллер правой информационной панели
|
||||
│ ├── install_tab.py # Вкладки установки (авто/ручная/сторонняя)
|
||||
│ ├── installed_tab.py # Вкладка «Установленные»
|
||||
│ ├── prefix_tab.py # Вкладка «Менеджер префиксов»
|
||||
│ ├── tab_names.py # Константы имён вкладок (TabNames)
|
||||
│ └── __init__.py
|
||||
├── autoinstall/ # Скрипты автоматической установки (43 приложения)
|
||||
├── manualinstall/ # Скрипты ручной установки (9 приложений)
|
||||
├── testinstall/ # Тестовые скрипты установки
|
||||
@@ -68,9 +93,14 @@ winehelper/
|
||||
│ ├── gui/ # Иконки приложения (SVG)
|
||||
│ └── handbook/ # Скриншоты для документации
|
||||
├── hiden_scripts/ # Скрытые/вспомогательные скрипты
|
||||
├── scripts/ # Вспомогательные скрипты
|
||||
│ └── check_sha256sum_list.sh # Проверка формата sha256sum.list
|
||||
├── auto_completion/ # Автодополнение для bash
|
||||
├── dependencies.sh # Установка системных зависимостей
|
||||
├── sha256sum.list # SHA256 хеши загружаемых файлов
|
||||
├── .pre-commit-config.yaml # Настройки pre-commit
|
||||
├── pyproject.toml # Настройки ruff
|
||||
├── .codespell-ignorewords # Исключения для codespell
|
||||
└── winehelper.desktop # Desktop-файл для запуска GUI
|
||||
```
|
||||
|
||||
@@ -92,11 +122,23 @@ winehelper/
|
||||
|
||||
Модульная архитектура PyQt5:
|
||||
|
||||
- **WineHelperGUI** — главное окно, управление вкладками
|
||||
- **PrefixManager** — CRUD операции с префиксами
|
||||
- **ApplicationInstaller** — установка приложений
|
||||
- **WineHelperGUI** — главное окно приложения (`winehelper_gui.py`)
|
||||
- **ScriptManager** — загрузка и обработка скриптов установки
|
||||
- **ScriptParser** — парсинг информации из скриптов установки
|
||||
- **Диалоги** — модальные окна для настроек и выбора
|
||||
- **PrefixManager** — CRUD операции с префиксами
|
||||
- **DesktopFileManager** — работа с .desktop файлами
|
||||
- **AppLauncher** — запуск и мониторинг установленных приложений
|
||||
- **SettingsManager** — чтение/запись настроек префиксов
|
||||
- **WinetricksService** — работа с компонентами Winetricks
|
||||
- **Диалоги** — модальные окна для настроек и выбора (Wine, Windows, компоненты, ассоциации файлов, предупреждение noexec)
|
||||
- **Утилиты** — логирование, работа с окружением, экспорт, сеточные макеты, управление иконками
|
||||
|
||||
#### Как CLI и GUI общаются
|
||||
|
||||
- **CLI → GUI.** `./winehelper gui` экспортирует окружение (`DATA_PATH`, `RUN_SCRIPT`, `USER_WORK_PATH`, `WH_DIST_DIR`, `WH_TMP_DIR`, `WH_DEVEL`, `WH_ICON_*`, `LICENSE_FILE`, `AGREEMENT`, `CHANGELOG_FILE`, …) и затем `exec`-ает `python3 winehelper_gui.py`. GUI читает его через `winehelperqt/utils/environment.py` (`EnvironmentConfig`). **Запустить GUI напрямую через `python3 winehelper_gui.py` нельзя** — нужно это окружение.
|
||||
- **GUI → CLI.** Каждое действие GUI (установка, удаление/бэкап/восстановление префикса, winetricks, запуск exe, killall…) собирает argv `winehelper <команда>` из `EnvironmentConfig.RUN_SCRIPT` и выполняет в `QProcess`, стримя stdout в диалог лога. Общая настройка `QProcess` — `winehelperqt/utils/process_utils.py`; диалоги долгих команд — `services/command_runner.py`, `services/install_runner.py`.
|
||||
- CLI определяет запуск из GUI (`WH_USE_GUI=1` + проверка родительского процесса) и пропускает интерактивный лицензионный запрос.
|
||||
- Завершённые объекты `QProcess` нужно удалять (см. `utils/process_utils.py`) — утечки были реальным багом.
|
||||
|
||||
### Скрипты установки
|
||||
|
||||
@@ -121,6 +163,27 @@ wine_run_install "${SETUP_EXE}" /S
|
||||
create_desktop "Имя ярлыка" "${EXE_PATH}" "icon_name"
|
||||
```
|
||||
|
||||
**Важно: скрипты не исполняются, а подключаются (`source`)** внутри функции `run_autoinstall`. Поэтому они вызывают функции CLI напрямую и видят его переменные. Любая синтаксическая ошибка в скрипте ломает сам CLI — перед коммитом проверяйте `bash -n` / shellcheck.
|
||||
|
||||
- `$1` — имя скрипта; `$2` — путь к дистрибутиву от пользователя для скриптов `manualinstall/` (при отсутствии `$2` скрипт обязан вызвать `fatal`).
|
||||
- `PROG_ICON="icon_name"` указывает на `image/<icon_name>.png`.
|
||||
- Режим определяется каталогом:
|
||||
- `autoinstall/` — полностью автоматическая установка, показывается в GUI;
|
||||
- `manualinstall/` — требуется дистрибутив пользователя;
|
||||
- `testinstall/` — экспериментальные, в GUI скрыты за галочкой;
|
||||
- `hiden_scripts/` — не перечисляются нигде, запускаются только по явному пути.
|
||||
- Каждый скачиваемый архив (сборки Wine, базовые префиксы, DXVK/VKD3D) должен иметь строку в `sha256sum.list` — `check_file_sum` отвергает незарегистрированные.
|
||||
|
||||
### Файлы `.whdb` и `last.conf`
|
||||
|
||||
Пофайловые настройки запуска — это тоже bash-сниппеты, которые подключаются (`source`):
|
||||
|
||||
- `create_desktop` создаёт `<exe>.whdb` рядом с установленным exe, копируя туда строки `export` из скрипта. Скрипты могут дописывать в этот файл дополнительный bash (хуки, фоновые задачи) — пример: `autoinstall/ksamu`.
|
||||
- `database/*.whdb` — общие профили, сопоставляемые по строке-комментарию `#name.exe`, когда своего `<exe>.whdb` нет (например, T-FLEX).
|
||||
- Перед каждым `wine_run` функция `init_database` подключает подходящий `.whdb`, затем `$WINEPREFIX/last.conf` (последние использованные настройки этого префикса). `last.conf` — это то, что правит диалог «настройки префикса» в GUI (`WH_WINDOWS_VER`, `WH_MAIN_DECORATED`, `WH_VIRTUAL_DESKTOP`, `WH_XDG_OPEN`, `WH_USE_CPCSP_PROXY` и т. д.).
|
||||
|
||||
Всё это подключается через `source`, поэтому синтаксическая ошибка в скрипте или `.whdb` ломает CLI — проверяйте `bash -n` / shellcheck перед коммитом.
|
||||
|
||||
## Режимы работы
|
||||
|
||||
### Системный режим
|
||||
@@ -133,15 +196,25 @@ create_desktop "Имя ярлыка" "${EXE_PATH}" "icon_name"
|
||||
При запуске из репозитория:
|
||||
- Рабочая директория: `~/test-winehelper/`
|
||||
- Данные берутся из текущей директории
|
||||
- Включается проверка синтаксиса скриптов
|
||||
- Устанавливается `WH_DEVEL=1` — в GUI появляется вкладка «Настройки» и dev-иконка
|
||||
- Префиксы, скачанные сборки Wine (`dist/`) и временные файлы лежат под `~/test-winehelper/`, поэтому запуски в режиме разработки не трогают реальную установку пользователя
|
||||
- Включается проверка синтаксиса: CLI прогоняет `bash -n` по самому себе, `dependencies.sh` и каждому скрипту в `autoinstall/`, `manualinstall/`, `testinstall/`, `database/`; при первой же синтаксической ошибке работа прерывается
|
||||
|
||||
| | системный режим | режим разработки |
|
||||
|---|---|---|
|
||||
| данные (`DATA_PATH`) | `/usr/share/winehelper/` | корень репозитория |
|
||||
| рабочая директория (`USER_WORK_PATH`) | `~/.local/share/winehelper/` | `~/test-winehelper/` |
|
||||
| `WH_DEVEL` | не задана | `1` |
|
||||
|
||||
## Команды CLI
|
||||
|
||||
```bash
|
||||
# Установка
|
||||
winehelper install list # Список доступных программ
|
||||
winehelper -i <script_name> # Установить программу
|
||||
winehelper -i <script_name> /path # Установить из дистрибутива
|
||||
winehelper -i <script_name> # Установить программу (autoinstall/)
|
||||
winehelper -i <script_name> /path # Установить из дистрибутива (manualinstall/, $2 = путь к дистрибутиву)
|
||||
winehelper -i "/path/to/script" # Установить из стороннего скрипта (режим "another")
|
||||
winehelper -i <script_name> --clear-pfx # Игнорировать BASE_PFX, строить префикс с нуля
|
||||
|
||||
# Управление префиксами
|
||||
winehelper create-prefix # Создать префикс
|
||||
@@ -150,23 +223,45 @@ winehelper backup-prefix [name] # Создать бэкап (.whpack)
|
||||
winehelper restore-prefix <path> # Восстановить из бэкапа
|
||||
|
||||
# Wine tools
|
||||
winehelper winefile # Файловый менеджер
|
||||
winehelper winecfg # Настройки Wine
|
||||
winehelper winereg # Редактор реестра
|
||||
winehelper winefile # Файловый менеджер
|
||||
winehelper wineconsole # Терминал Wine (cmd.exe)
|
||||
winehelper winetricks [component] # Winetricks
|
||||
winehelper <file.exe> # Запустить exe в префиксе
|
||||
winehelper run <exe_name> # Запустить установленную программу (префикс берётся из .whdb)
|
||||
winehelper debug [флаги] [программа] # Запуск программы в отладчике winedbg
|
||||
winehelper killall # Остановить все процессы Wine, запущенные из USER_WORK_PATH
|
||||
winehelper run-in-prefix <префикс> <файл> # Запустить произвольный exe/msi в указанном префиксе
|
||||
winehelper --help # Полный список подкоманд (функция wh_info)
|
||||
|
||||
# Управление компонентами
|
||||
winehelper install-dxvk [version] # Установить DXVK
|
||||
winehelper install-vkd3d [version] # Установить VKD3D
|
||||
winehelper change-wine [version] # Сменить версию Wine
|
||||
winehelper clear-winetricks-cache # Очистить кэш Winetricks
|
||||
|
||||
# Другое
|
||||
winehelper --debug <command> # Режим отладки
|
||||
winehelper --changelog # История изменений
|
||||
winehelper gui # Запустить GUI
|
||||
```
|
||||
|
||||
### Переменные окружения и неинтерактивный режим
|
||||
|
||||
```bash
|
||||
WH_LOG_LEVEL=debug ./winehelper gui # Уровень логов GUI: debug/info/warning/error
|
||||
WH_SKIP_DEPS=1 ./winehelper ... # Пропустить проверку i586-зависимостей (rpm -q 32-битных библиотек)
|
||||
yes | ./winehelper -i <script> # Неинтерактивно: лицензионный запрос — это `read -p`;
|
||||
# когда stdin не tty, проверка зависимостей тоже пропускается
|
||||
```
|
||||
|
||||
## Как работает выполнение (runtime)
|
||||
|
||||
- `wine_run` сопоставляет тип файла способу запуска: `.msi` → `msiexec /i` (для тихой установки передавайте `/qn`), `.exe` → `start /wait /high /unix` (переменная `WINE_WIN_START`).
|
||||
- Вывод Wine каждого запуска дублируется (`tee`) в `~/winehelper_backup_log/<префикс>_<exe>_<дата>.log`.
|
||||
- По `sha256sum.list` проверяются **только** сборки Wine, базовые префиксы и DXVK/VKD3D. Скачанные дистрибутивы приложений **не** проверяются (`check_file_sum` отвергает лишь незарегистрированные архивы из первого списка).
|
||||
- Внутренний конвейер CLI: инициализация окружения/путей → помощники (`fatal`, `print_*`, `try_download`, `unpack`, `check_file_sum`) → управление Wine/DXVK/префиксами (`init_wine_ver`, `init_wineprefix`, `prepair_wine`) → `wine_run` / `wine_run_install` → интеграция с рабочим столом (`create_desktop`) → большой диспетчер `case "$arg1"` внизу файла. Новые подкоманды добавляются в этот `case` и в текст `--help` (функция `wh_info`).
|
||||
|
||||
## Разработка
|
||||
|
||||
### Запуск в режиме разработки
|
||||
@@ -180,6 +275,78 @@ winehelper gui # Запустить GUI
|
||||
./winehelper -i alta-expert
|
||||
```
|
||||
|
||||
### Стиль коммитов
|
||||
|
||||
Коммиты оформляются по Conventional Commits с указанием области: `feat(gui): ...`, `refactor(gui): ...`, `fix(gui): ...` и т. п. Документация, комментарии, сообщения лога и строки интерфейса — на русском; так же оформляется и новый код.
|
||||
|
||||
### Pre-commit
|
||||
|
||||
Проект использует [pre-commit](https://pre-commit.com/) для автоматической проверки кода перед коммитом.
|
||||
|
||||
#### Установка
|
||||
|
||||
```bash
|
||||
# Установить pre-commit
|
||||
pip install --user pre-commit
|
||||
|
||||
# Установить git-хуки
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
#### Использование
|
||||
|
||||
Проверка запускается автоматически при обычном `git commit`:
|
||||
|
||||
```bash
|
||||
git add some-file.py
|
||||
git commit -m "feat: описание изменения"
|
||||
```
|
||||
|
||||
Если хуки проходят — коммит создаётся. Если падают — коммит отменяется, а в терминале выводится список ошибок.
|
||||
|
||||
Проверить все файлы вручную:
|
||||
|
||||
```bash
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
Проверить только staged-файлы:
|
||||
|
||||
```bash
|
||||
pre-commit run
|
||||
```
|
||||
|
||||
Обновить версии хуков:
|
||||
|
||||
```bash
|
||||
pre-commit autoupdate
|
||||
```
|
||||
|
||||
#### Включённые проверки
|
||||
|
||||
- `trailing-whitespace`, `end-of-file-fixer`, `mixed-line-ending` — форматирование файлов
|
||||
- `check-ast` — синтаксис Python
|
||||
- `debug-statements` — отладочные вызовы (`breakpoint()`, `pdb`)
|
||||
- `detect-private-key` — случайно закоммиченные ключи
|
||||
- `bash-syntax-check` — синтаксис bash-скриптов
|
||||
- `py-compile` — компиляция Python-файлов (`python3 -m py_compile`)
|
||||
- `shellcheck` — линтинг bash-скриптов
|
||||
- `shfmt` — форматирование bash-скриптов
|
||||
- `ruff` — линтинг Python (импорты, отступы, line-length=120)
|
||||
- `ruff-format` — автоформатирование Python
|
||||
- `codespell` — проверка орфографии
|
||||
- `sha256sum.list format check` — валидация формата `sha256sum.list`
|
||||
|
||||
#### Важные нюансы
|
||||
|
||||
- Хук проверяет только файлы из индекса (`git add`).
|
||||
- Чтобы пропустить проверку (не рекомендуется):
|
||||
```bash
|
||||
git commit --no-verify
|
||||
```
|
||||
- `pyproject.toml` содержит настройки `ruff` (line-length=120, select правил).
|
||||
- Слова, которые `codespell` считает опечатками, но которые являются корректными для проекта, перечислены в `.codespell-ignorewords`.
|
||||
|
||||
### Добавление нового скрипта установки
|
||||
|
||||
1. Создать файл в `autoinstall/` (для автоматической установки) или `manualinstall/` (требуется дистрибутив)
|
||||
@@ -195,12 +362,38 @@ winehelper gui # Запустить GUI
|
||||
- Проверять наличие переменных перед использованием
|
||||
- Использовать функции обработки ошибок (`fatal`, `print_error`)
|
||||
- Экранировать все пути с помощью кавычек
|
||||
- Предпочитать готовые помощники (`try_remove_file`, `try_copy_dir`, `create_new_dir`, `try_download`) сырым `rm`/`cp`/`wget`
|
||||
|
||||
**Python:**
|
||||
- Импортировать из `winehelperqt` относительно
|
||||
- Использовать type hints где возможно
|
||||
- Импорты — только абсолютные (`from winehelperqt.x import y`). Относительные (`from .`, `from ..`) запрещены
|
||||
- Qt импортируется ТОЛЬКО через слой совместимости: `from winehelperqt.qt_compat import Qt, QWidget, ...`. Прямые импорты `PyQt5`/`PyQt6` в коде запрещены (единственное исключение — сам `winehelperqt/qt_compat.py`). Это нужно для работы на PyQt6 (p11+) и PyQt5 (p10) из единой кодовой базы:
|
||||
- enum'ы только в scoped-форме: `Qt.AlignmentFlag.AlignCenter`, `QFont.Weight.Bold`, `QMessageBox.StandardButton.Yes` (работает в PyQt5 ≥5.11 и PyQt6);
|
||||
- `exec()` вместо `exec_()`;
|
||||
- сравнение int из сигналов (например `QCheckBox.stateChanged`) с enum — через `Qt.CheckState(state) == Qt.CheckState.Checked`;
|
||||
- для теста конкретного binding'а: `WH_QT_BINDING=pyqt5 ./winehelper gui` (или `pyqt6`);
|
||||
- выбор binding'а также сохраняется во вкладке «Настройки» (ключ `qt_binding` в `~/.config/winehelper/logging.conf`: auto/pyqt6/pyqt5; приоритет: `WH_QT_BINDING` > конфиг > auto); применяется при перезапуске GUI — переключение «на лету» в одном процессе невозможно (PyQt5 и PyQt6 нельзя загружать вместе)
|
||||
- Документация на русском языке
|
||||
- Классы должны наследоваться от соответствующих QWidget
|
||||
- Код проходит проверки `ruff` и `ruff-format` (см. раздел Pre-commit)
|
||||
- Желательно добавлять type hints для новых функций и методов
|
||||
- Для вывода сообщений использовать логгер, а не `print()`:
|
||||
```python
|
||||
from winehelperqt.logger import get_logger
|
||||
logger = get_logger(__name__)
|
||||
```
|
||||
Логгер настраивается один раз в `main()` (`winehelper_gui.py`) через `setup_logger()`.
|
||||
Уровень задаётся переменной окружения `WH_LOG_LEVEL` (debug/info/warning/error, по умолчанию info)
|
||||
либо во вкладке «Настройки» GUI (вкладка отображается только в режиме разработки — при тестовом запуске из репозитория, когда `WH_DEVEL=1`).
|
||||
Вывод идёт в stdout — виден при запуске GUI из терминала (`./winehelper gui`).
|
||||
При выводе в терминал (tty) уровни подсвечиваются цветом (`ColoredFormatter`): DEBUG — голубой, INFO — зелёный, WARNING — жёлтый, ERROR — красный, CRITICAL — жирный красный.
|
||||
|
||||
## Воспроизведение в headless-окружении (podman)
|
||||
|
||||
Использовалось для бага #59578. Образ `registry.altlinux.org/sisyphus/base` уже содержит репозиторий `x86_64-i586`. От root:
|
||||
`apt-get install apt-repo xorg-xvfb xdotool ImageMagick winetricks 7-zip icoutils cabextract procps psmisc which`,
|
||||
затем `bash dependencies.sh` (нужен `apt-repo`), `useradd -m test`, `mkdir -m 1777 /tmp/.X11-unix`.
|
||||
|
||||
CLI запускать от непривилегированного пользователя (от root он отказывается), репозиторий монтировать read-only (режим разработки → `~/test-winehelper/`), поднять `Xvfb :99` + `DISPLAY=:99` и обязательно задать **`LANG=ru_RU.UTF-8`** — без UTF-8 локали установка MSI с кириллическими путями откатывается (`cabinet_copy_file ... error 3`), а интерфейсы приложений рисуют `?`. Скриншоты: `import -window root out.png`; список окон: `xdotool search --name ""`.
|
||||
|
||||
## Безопасность
|
||||
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
История изменений:
|
||||
|
||||
0.13.0
|
||||
* обновлен графический режим Qt5:
|
||||
- исправлены/улучшены основные функции
|
||||
- запуск утилит Wine выполняется через winehelper (CLI) вместо прямого вызова wine
|
||||
- добавлены индикаторы-иконки на кнопках версий Wine и DXVK/VKD3D
|
||||
- добавлена сортировка версий Wine по убыванию
|
||||
- удалена вкладка "Загрузка" в окне выбора версии Wine
|
||||
- исправлена автопрокрутка списка в менеджере компонентов при поиске
|
||||
- исправлено определение .exe файлов без учета регистра
|
||||
- скрытие ярлыка установленного приложения, если .exe файл отсутствует
|
||||
- добавлена корректная активация CPCSP_PROXY в дополнительных настройках префикса
|
||||
- добавлена принудительная переустановка cpcsp_proxy при включении
|
||||
* добавлены версии wine:
|
||||
- wine-11.0-alt-tkg
|
||||
- wine-9.0-alt-tkg (i586, amd64, wow64)
|
||||
- wine-10.0-alt-tkg (i586, amd64, wow64)
|
||||
- wine-10.0-alt-scadoffice-wow64
|
||||
* удалены неиспользуемые версии wine
|
||||
* обновлены скрипты установок:
|
||||
- t-flex: единый префикс для всех версий, используется wine с исправлением всплывающих окон и 3D на Wayland
|
||||
- emias: используется wine с исправлением сворачивания окон
|
||||
- scadoffice: используется специальная сборка wine (демо версия программы)
|
||||
- olimp: добавлен itss в префикс, исправлен фокус окна и окна справки
|
||||
- ctm-service: исправлено имя exe для desktop файла
|
||||
- daobit-cservice: исправлено имя установщика и замена CService32.exe
|
||||
* добавлена поддержка запуска сторонних скриптов установки
|
||||
* добавлена минимальная генерация .whdb файла при ручном создании ярлыка
|
||||
* возвращено уведомление о монтировании home с флагом noexec
|
||||
* добавлена возможность выбрать рабочий каталог для WineHelper:
|
||||
winehelper data-path "/путь/к/каталогу"
|
||||
|
||||
0.12.0
|
||||
* исправлена работа cpcsp_proxy при монтировании home с флагом noexec
|
||||
* обновлены все скрипты установки ВЭД и СТМ
|
||||
|
||||
@@ -55,7 +55,7 @@ modified by someone else and passed on, the recipients should know
|
||||
that what they have is not the original version, so that the original
|
||||
author's reputation will not be affected by problems that might be
|
||||
introduced by others.
|
||||
|
||||
|
||||
Finally, software patents pose a constant threat to the existence of
|
||||
any free program. We wish to make sure that a company cannot
|
||||
effectively restrict the users of a free program by obtaining a
|
||||
@@ -111,7 +111,7 @@ modification follow. Pay close attention to the difference between a
|
||||
"work based on the library" and a "work that uses the library". The
|
||||
former contains code derived from the library, whereas the latter must
|
||||
be combined with the library in order to run.
|
||||
|
||||
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
@@ -158,7 +158,7 @@ Library.
|
||||
You may charge a fee for the physical act of transferring a copy,
|
||||
and you may at your option offer warranty protection in exchange for a
|
||||
fee.
|
||||
|
||||
|
||||
2. You may modify your copy or copies of the Library or any portion
|
||||
of it, thus forming a work based on the Library, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
@@ -216,7 +216,7 @@ instead of to this License. (If a newer version than version 2 of the
|
||||
ordinary GNU General Public License has appeared, then you can specify
|
||||
that version instead if you wish.) Do not make any other change in
|
||||
these notices.
|
||||
|
||||
|
||||
Once this change is made in a given copy, it is irreversible for
|
||||
that copy, so the ordinary GNU General Public License applies to all
|
||||
subsequent copies and derivative works made from that copy.
|
||||
@@ -267,7 +267,7 @@ Library will still fall under Section 6.)
|
||||
distribute the object code for the work under the terms of Section 6.
|
||||
Any executables containing that work also fall under Section 6,
|
||||
whether or not they are linked directly with the Library itself.
|
||||
|
||||
|
||||
6. As an exception to the Sections above, you may also combine or
|
||||
link a "work that uses the Library" with the Library to produce a
|
||||
work containing portions of the Library, and distribute that work
|
||||
@@ -329,7 +329,7 @@ restrictions of other proprietary libraries that do not normally
|
||||
accompany the operating system. Such a contradiction means you cannot
|
||||
use both them and the Library together in an executable that you
|
||||
distribute.
|
||||
|
||||
|
||||
7. You may place library facilities that are a work based on the
|
||||
Library side-by-side in a single library together with other library
|
||||
facilities not covered by this License, and distribute such a combined
|
||||
@@ -370,7 +370,7 @@ subject to these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties with
|
||||
this License.
|
||||
|
||||
|
||||
11. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
@@ -422,7 +422,7 @@ conditions either of that version or of any later version published by
|
||||
the Free Software Foundation. If the Library does not specify a
|
||||
license version number, you may choose any version ever published by
|
||||
the Free Software Foundation.
|
||||
|
||||
|
||||
14. If you wish to incorporate parts of the Library into other free
|
||||
programs whose distribution conditions are incompatible with these,
|
||||
write to the author to ask for permission. For software which is
|
||||
@@ -456,7 +456,7 @@ SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
|
||||
DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
|
||||
How to Apply These Terms to Your New Libraries
|
||||
|
||||
If you develop a new library, and you want it to be of the greatest
|
||||
|
||||
@@ -72,6 +72,12 @@
|
||||
$ winehelper -i is-record-station "/путь/до/установочного_файла"
|
||||
```
|
||||
|
||||
3. **Установка из стороннего скрипта** — можно передать путь к собственному скрипту установки, подготовленному по шаблону WineHelper.
|
||||
Пример:
|
||||
```
|
||||
$ winehelper -i "/путь/до/скрипта_установки"
|
||||
```
|
||||
|
||||
### Процесс установки приложений из списка скриптов
|
||||
|
||||
При первом запуске **WineHelper** проверяются и при необходимости устанавливаются дополнительные зависимости (запрашиваются права root).
|
||||
@@ -142,7 +148,7 @@ $ winehelper "/путь/до/установочного_файла.exe"
|
||||
2 - Префикс2
|
||||
Выберите префикс (0-2):
|
||||
```
|
||||
Необходимо указать номер удаляемого префикса и нажать клавишу **Enter**.
|
||||
Необходимо указать номер удаляемого префикса и нажать клавишу **Enter**.
|
||||
После выбора префикса автоматически:
|
||||
- Создаётся копия префикса.
|
||||
- Внутри префикса сохраняется используемая версия WINE.
|
||||
@@ -171,7 +177,6 @@ WineHelper предоставляет доступ к основным инст
|
||||
winehelper winecfg # запуск wine конфигуратора для префикса
|
||||
winehelper winereg # запуск редактора реестра для префикса
|
||||
winehelper wineconsole # запуск терминала wine (cmd.exe)
|
||||
winehelper winetricks # запуск графического интерфейса winetricks
|
||||
winehelper winetricks [компонент] # автоматическая установка дополнительного компонента в префикс
|
||||
```
|
||||
После выполнения любой из вышеперечисленных команд отображается список существующих префиксов:
|
||||
@@ -219,24 +224,40 @@ WineHelper предоставляет доступ к основным инст
|
||||
install list список возможных установочных скриптов
|
||||
install [скрипт] запустить скрипт установки программы
|
||||
install [скрипт] --clear-pfx не использовать готовый префикс для установки ПО
|
||||
install "путь/до/скрипта" запустить сторонний скрипт установки
|
||||
|
||||
install-dxvk [версия|none|list] установить, удалить или показать версии DXVK
|
||||
install-vkd3d [версия|none|list] установить, удалить или показать версии VKD3D
|
||||
change-wine [версия] изменить версию Wine для текущего префикса
|
||||
|
||||
installed список установленных программ
|
||||
run [программа] запуск программы (отладка)
|
||||
run [программа] запуск программы
|
||||
debug [флаги] [программа] запуск программы в отладчике winedbg
|
||||
Флаги:
|
||||
--auto, -a автоматический backtrace
|
||||
--gdb, -g режим gdb
|
||||
--interactive, -i интерактивный режим
|
||||
remove-all удалить WineHelper и все связанные данные
|
||||
create-prefix создать префикс
|
||||
remove-prefix [имя_префикса] удалить префикс и все связанные данные
|
||||
backup-prefix [имя_префикса] создать резервную копию префикса
|
||||
restore-prefix \"путь/до/whpack\" восстановить префикс из резервной копии
|
||||
clear-winetricks-cache очистить кэш Winetricks
|
||||
|
||||
winecfg запуск wine конфигуратора для префикса
|
||||
winereg запуск редактора реестра для префикса
|
||||
winefile запуск файлового менеджера wine
|
||||
wineconsole запуск терминала wine (cmd.exe)
|
||||
control запуск панели управления wine
|
||||
uninstaller запуск удаления программ wine
|
||||
winetricks [компонент] автоматическая установка дополнительного компонента в префикс
|
||||
killall остановить все процессы Wine
|
||||
run-in-prefix <префикс> <файл> запустить файл в указанном префиксе
|
||||
|
||||
Параметры:
|
||||
--help показать эту справку и выйти
|
||||
--version показать информацию о пакете и его версии
|
||||
--changelog показать историю изменений
|
||||
--debug [команда] включить режим логирования работы WINE
|
||||
</pre>
|
||||
|
||||
</details>
|
||||
|
||||
@@ -4,25 +4,21 @@ _winehelper_completions() {
|
||||
COMPREPLY=()
|
||||
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
opts="--help --version --debug install installed install-dxvk install-vkd3d -r -i remove-all --clear-pfx killall remove-prefix backup-prefix restore-prefix create-prefix --changelog changelog change-wine clear-winetricks-cache"
|
||||
wine_cmd="winecfg winereg winefile wineconsole winetricks desktop regedit explorer cmd run"
|
||||
opts="--help --version install installed install-dxvk install-vkd3d -r -i remove-all killall remove-prefix backup-prefix restore-prefix create-prefix change-wine change-windows-ver clear-winetricks-cache gui data-path version help --changelog changelog create-base-pfx init-prefix run-in-prefix debug"
|
||||
wine_cmd="winecfg winereg winefile wineconsole winetricks desktop regedit explorer cmd run uninstaller control"
|
||||
|
||||
case "${prev}" in
|
||||
winehelper)
|
||||
COMPREPLY=( $(compgen -W "${opts} ${wine_cmd}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
--debug)
|
||||
COMPREPLY=( $(compgen -W "${wine_cmd}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
install|-i)
|
||||
local scripts="$(ls -1 /usr/share/winehelper/autoinstall) $(ls -1 /usr/share/winehelper/manualinstall)"
|
||||
COMPREPLY=( $(compgen -W "list ${scripts}" -- "${cur}") )
|
||||
local scripts="$(ls -1 /usr/share/winehelper/autoinstall 2>/dev/null) $(ls -1 /usr/share/winehelper/manualinstall 2>/dev/null) $(ls -1 /usr/share/winehelper/testinstall 2>/dev/null)"
|
||||
COMPREPLY=( $(compgen -W "list --clear-pfx ${scripts}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
run|installed)
|
||||
local installed=$(ls -1 ~/.local/share/winehelper/ | grep ".desktop" | sed 's/.desktop//')
|
||||
local installed=$(ls -1 ~/.local/share/winehelper/ 2>/dev/null | grep ".desktop" | sed 's/.desktop//')
|
||||
COMPREPLY=( $(compgen -W "${installed}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
@@ -31,7 +27,17 @@ _winehelper_completions() {
|
||||
COMPREPLY=( $(compgen -W "${prefixes}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
run-in-prefix)
|
||||
local prefixes=$(ls -1 ~/.local/share/winehelper/prefixes 2>/dev/null)
|
||||
COMPREPLY=( $(compgen -W "${prefixes}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
restore-prefix)
|
||||
COMPREPLY=( $(compgen -f -X '!*.whpack' -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
data-path)
|
||||
COMPREPLY=( $(compgen -d -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
install-dxvk|install-vkd3d)
|
||||
@@ -48,7 +54,15 @@ _winehelper_completions() {
|
||||
COMPREPLY=( $(compgen -W "system ${wine_versions}" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
change-windows-ver)
|
||||
COMPREPLY=( $(compgen -W "2k xp xp64 2k3 vista 7 8 81 10 11" -- "${cur}") )
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
if [[ "${COMP_WORDS[COMP_CWORD-2]}" == "run-in-prefix" ]] ; then
|
||||
COMPREPLY=( $(compgen -f -- "${cur}") )
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ _winehelper() {
|
||||
|
||||
opts=(
|
||||
'--help[Вывести справку]'
|
||||
'help[Вывести справку]'
|
||||
'--version[Показать информацию о пакете и его версии]'
|
||||
'--debug[Режим отладки]'
|
||||
'version[Показать информацию о пакете и его версии]'
|
||||
'install[Запустить скрипт установки программы]'
|
||||
'install-dxvk[Установить/удалить DXVK]'
|
||||
'install-vkd3d[Установить/удалить VKD3D]'
|
||||
@@ -14,14 +15,22 @@ _winehelper() {
|
||||
'-r[Запуск программы (отладка)]'
|
||||
'-i[Запустить скрипт установки программы]'
|
||||
'remove-all[Удалить WineHelper и все связанные данные]'
|
||||
'--clear-pfx[Очистить префикс \[имя_префикса\]]'
|
||||
'killall[Убить все процессы]'
|
||||
'create-prefix[Создать новый префикс]'
|
||||
'remove-prefix[Удалить префикс и все связанные данные]'
|
||||
'backup-prefix[Создать резерную копию префикса]'
|
||||
'restore-prefix[восстановить префикс из резервной копии "путь/до/whpack"]'
|
||||
'backup-prefix[Создать резервную копию префикса]'
|
||||
'restore-prefix[Восстановить префикс из резервной копии "путь/до/whpack"]'
|
||||
'change-wine[Изменить версию Wine для префикса]'
|
||||
'change-windows-ver[Изменить версию Windows для префикса]'
|
||||
'clear-winetricks-cache[Очистить кэш Winetricks]'
|
||||
'gui[Запустить графический интерфейс]'
|
||||
'data-path[Изменить рабочий каталог WineHelper]'
|
||||
'create-base-pfx[Создать базовый шаблонный префикс]'
|
||||
'init-prefix[Инициализировать префикс]'
|
||||
'run-in-prefix[Запустить файл в указанном префиксе]'
|
||||
'debug[Запуск программы в отладчике]'
|
||||
'--changelog[Показать историю изменений]'
|
||||
'changelog[Показать историю изменений]'
|
||||
)
|
||||
|
||||
wine_cmd=(
|
||||
@@ -34,6 +43,8 @@ _winehelper() {
|
||||
regedit
|
||||
explorer
|
||||
cmd
|
||||
uninstaller
|
||||
control
|
||||
'run[Запуск программы (отладка)]'
|
||||
)
|
||||
|
||||
@@ -47,23 +58,28 @@ _winehelper() {
|
||||
_values 'winehelper options' "${opts[@]}" "${wine_cmd[@]}"
|
||||
;;
|
||||
args)
|
||||
if [[ "$words[2]" == "run-in-prefix" ]] && [[ ${#words[@]} -gt 3 ]] ; then
|
||||
_files
|
||||
return
|
||||
fi
|
||||
|
||||
case $words[2] in
|
||||
--debug)
|
||||
_values 'wine commands' "${wine_cmd[@]}"
|
||||
;;
|
||||
install|-i)
|
||||
_get_list_for_install
|
||||
;;
|
||||
run|installed)
|
||||
run|installed|debug)
|
||||
_get_installed_list
|
||||
;;
|
||||
remove-prefix|backup-prefix)
|
||||
remove-prefix|backup-prefix|run-in-prefix)
|
||||
_get_prefixes
|
||||
;;
|
||||
create-prefix)
|
||||
create-prefix|init-prefix|create-base-pfx|gui|killall|clear-winetricks-cache|remove-all|version|--version|help|--help|changelog|--changelog)
|
||||
;;
|
||||
restore-prefix)
|
||||
_files
|
||||
_files -g '*.whpack'
|
||||
;;
|
||||
data-path)
|
||||
_path_files -/
|
||||
;;
|
||||
install-dxvk)
|
||||
_get_component_versions 'install-dxvk'
|
||||
@@ -74,6 +90,11 @@ _winehelper() {
|
||||
change-wine)
|
||||
_get_wine_versions
|
||||
;;
|
||||
change-windows-ver)
|
||||
_values 'windows versions' 2k xp xp64 2k3 vista 7 8 81 10 11
|
||||
;;
|
||||
winecfg|winereg|winefile|wineconsole|winetricks|desktop|regedit|explorer|cmd|uninstaller|control)
|
||||
;;
|
||||
*)
|
||||
_values 'winehelper options' "${opts[@]}" "${wine_cmd[@]}"
|
||||
;;
|
||||
@@ -117,8 +138,11 @@ _get_prefixes () {
|
||||
}
|
||||
|
||||
_get_list_for_install () {
|
||||
|
||||
packages=(${(f)"$(winehelper install list 2>/dev/null |
|
||||
local -a packages
|
||||
|
||||
packages=(--clear-pfx)
|
||||
|
||||
packages+=(${(f)"$(winehelper install list 2>/dev/null |
|
||||
awk -F ' - ' '
|
||||
/^[^ ]+ - \("/ {next}
|
||||
/^[[:space:]]*$/ {next}
|
||||
@@ -131,14 +155,8 @@ _get_list_for_install () {
|
||||
if (name && desc) print name ":" desc
|
||||
}
|
||||
')"})
|
||||
|
||||
packages+=(
|
||||
scadoffice:"SCAD Office"
|
||||
t-flex-cad17-applications:"Приложения для T-FLEX CAD 17"
|
||||
t-flex-cad17-resources:"Обучающие материалы T-FLEX CAD 17"
|
||||
)
|
||||
|
||||
_describe 'packages' packages
|
||||
_describe 'packages' packages
|
||||
}
|
||||
|
||||
_get_installed_list () {
|
||||
|
||||
@@ -24,5 +24,3 @@ if try_download "$AUTOINSTALL_URL" "$AUTOINSTALL_EXE" ; then
|
||||
|
||||
create_desktop "$PROG_NAME" "$DRIVE_C/Program Files/A'PROPOS-Z/Apropos-Z.exe" "$PROG_ICON"
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# info_ru: Это мощный, современный инструмент быстрой и качественной разработки АСУТП
|
||||
########################################################################
|
||||
export PROG_URL="https://www.krug2000.ru"
|
||||
export WH_WINE_USE="wine-11.12-alt-sk2000-i586"
|
||||
export WINEPREFIX="scada-krug-2000"
|
||||
export PROG_NAME="SCADA КРУГ 2000"
|
||||
export PROG_ICON="krugtaskmanager"
|
||||
export BASE_PFX="scadakrug2k_pfx_v01"
|
||||
export WH_WINDOWS_VER="10"
|
||||
export WINEARCH="win32"
|
||||
export INSTALL_DLL="dotnet48 vcrun6 vcrun2008 vcrun2022 d3dcompiler_47 mdac28 corefonts riched20 wsh57"
|
||||
|
||||
KRUG_VERSION="4_4"
|
||||
AUTOINSTALL_EXE="${WH_TMP_DIR}/01_scada_v${KRUG_VERSION}_release.zip"
|
||||
AUTOINSTALL_UNPACK="${WH_TMP_DIR}/scada_krug_${KRUG_VERSION}"
|
||||
|
||||
prepair_wine
|
||||
if try_download "https://www.krug2000.ru/sp/01_scada_v${KRUG_VERSION}_release.zip" "${AUTOINSTALL_EXE}"; then
|
||||
unpack "${AUTOINSTALL_EXE}" "${AUTOINSTALL_UNPACK}"
|
||||
try_remove_file "$AUTOINSTALL_EXE"
|
||||
|
||||
# Корневой setup.exe в архиве — HTML-меню автозапуска, тихую установку не поддерживает (bug #59578).
|
||||
# Настоящий инсталлятор — DeveloperStudio/SCADA KRUG 2000.msi, ставим его напрямую.
|
||||
AUTOINSTALL_SETUP="$(find "$AUTOINSTALL_UNPACK" -iname "SCADA KRUG 2000.msi" -print -quit)"
|
||||
[[ -f "$AUTOINSTALL_SETUP" ]] || fatal "В архиве дистрибутива не найден файл SCADA KRUG 2000.msi."
|
||||
|
||||
wine_run_install "${AUTOINSTALL_SETUP}" /qn
|
||||
# Инсталлятор оставляет запущенными KrugTaskManager.exe, SMTListener.exe, KrugTaskStarter.exe
|
||||
"$WINESERVER" -k
|
||||
try_remove_dir "$AUTOINSTALL_UNPACK"
|
||||
|
||||
create_desktop "Менеджер задач" "$DRIVE_C/Program Files/Krug2000/Bin/KrugTaskManager.exe" "$PROG_ICON"
|
||||
fi
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# info_ru: ZET 9 Lite — бесплатная астрологическая программа: натальные карты, транзиты, прогнозы, расчёты по швейцарским эфемеридам.
|
||||
########################################################################
|
||||
export PROG_URL="https://astrozet.net"
|
||||
export WH_WINE_USE="wine-11.12-alt-tkg-wow64"
|
||||
export WINEPREFIX="zet9"
|
||||
export PROG_NAME="ZET 9 Lite"
|
||||
export PROG_ICON="zet9"
|
||||
export BASE_PFX="none"
|
||||
export WINEARCH="win64"
|
||||
export WH_WINDOWS_VER="10"
|
||||
|
||||
AUTOINSTALL_URL="https://astrozet.net/upload/ZET9SETUP229-RU.exe"
|
||||
AUTOINSTALL_EXE="${WH_TMP_DIR}/ZET9SETUP229-RU.exe"
|
||||
|
||||
prepair_wine
|
||||
if try_download "$AUTOINSTALL_URL" "$AUTOINSTALL_EXE" ; then
|
||||
wine_run_install "$AUTOINSTALL_EXE" /S
|
||||
try_remove_file "$AUTOINSTALL_EXE"
|
||||
|
||||
create_desktop "$PROG_NAME" "$DRIVE_C/ZET 9/zet.exe" "$PROG_ICON"
|
||||
fi
|
||||
+6
-9
@@ -1,19 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
##### CHECK ROOT #####
|
||||
if [[ $(id -u) -ne 0 ]] ; then
|
||||
if [[ $(id -u) -ne 0 ]]; then
|
||||
echo "Перезапустите скрипт $0 от root пользователя!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
##### MESSAGES FUNCTIONS #####
|
||||
if [[ -t 0 ]] ; then
|
||||
print_error () { printf "\E[31m%s Ошибка: $* %s\e[0m\n" ;}
|
||||
if [[ -t 0 ]]; then
|
||||
print_error() { printf "\E[31m%s Ошибка: $* %s\e[0m\n"; }
|
||||
else
|
||||
print_error () { echo -e "Ошибка: $*" ;}
|
||||
print_error() { echo -e "Ошибка: $*"; }
|
||||
fi
|
||||
|
||||
fatal () {
|
||||
fatal() {
|
||||
print_error "$@ Аварийное завершение работы WineHelper!"
|
||||
exit 1
|
||||
}
|
||||
@@ -29,7 +29,4 @@ apt-get update || fatal "Не удалось обновить список до
|
||||
##### INSTALL DEPENDENCIES #####
|
||||
# fonts-ttf-ms
|
||||
|
||||
apt-get install -y i586-{wine,glibc-core,libstdc++6,glibc-pthread,glibc-nss,\
|
||||
libnm,libnss,libnss-mdns,libnsl1,libunwind,libunixODBC2,ocl-icd,libfreetype,\
|
||||
libcups,libfontconfig1,libgnutls30,libGL,libEGL,libvulkan1,xorg-dri-swrast,\
|
||||
xorg-dri-intel,xorg-dri-radeon} || fatal "Не удалось установить зависимости."
|
||||
apt-get install -y i586-{wine,glibc-core,libstdc++6,glibc-pthread,glibc-nss,libnm,libnss,libnss-mdns,libnsl1,libunwind,libunixODBC2,ocl-icd,libfreetype,libcups,libfontconfig1,libgnutls30,libGL,libEGL,libvulkan1,xorg-dri-swrast,xorg-dri-intel,xorg-dri-radeon} || fatal "Не удалось установить зависимости."
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# info_ru: ADEM 9.0 — интегрированная система CAD/CAM/CAPP/PDM для конструкторско-технологической подготовки производства. Ручная установка из дистрибутива CADCAMCAPPAdem90st.exe.
|
||||
########################################################################
|
||||
export PROG_URL="https://adem.ru"
|
||||
export WH_WINE_USE="wine-11.18-alt-adem-wow64"
|
||||
export WINEPREFIX="adem"
|
||||
export PROG_NAME="ADEM 9.0"
|
||||
export PROG_ICON="adem"
|
||||
export BASE_PFX="adem_pfx_x64_v02"
|
||||
export WINEARCH="win64"
|
||||
export WH_WINDOWS_VER="10"
|
||||
export INSTALL_DLL="dotnet48 vcrun2008 vcrun2010 vcrun2012 vcrun2022 corefonts"
|
||||
|
||||
if [[ -f "$2" ]] \
|
||||
&& [[ $(basename "$2") =~ ^CADCAMCAPPAdem.*\.exe$ ]]
|
||||
then
|
||||
prepair_wine
|
||||
|
||||
# Inno Setup. Файлы CADCAMCAPPAdem90st-N.bin должны лежать рядом с exe.
|
||||
# Установщик сам вызывает SwitchLang.exe (xcopy русских ресурсов) и regsvr32.
|
||||
wine_run_install "$2" # /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
|
||||
|
||||
ADEM_PATH="$DRIVE_C/Program Files (x86)/Adem Group/Adem90"
|
||||
create_desktop "$PROG_NAME" "$ADEM_PATH/2-d/admsrv90r.exe" "$PROG_ICON"
|
||||
else
|
||||
fatal "Не найден файл установки CADCAMCAPPAdem90st.exe. Перезапустите по примеру:
|
||||
winehelper install $1 \"/путь/до/CADCAMCAPPAdem90st.exe\""
|
||||
fi
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
# info_ru: Установщик "ЕМИАС" - Единая медицинская информационно-аналитическая система
|
||||
########################################################################
|
||||
export PROG_URL="https://emias.info/"
|
||||
export WH_WINE_USE="wine-11.0-alt-tkg-wow64"
|
||||
export WH_WINE_USE="wine-11.0-alt-emias-wow64"
|
||||
export WINEPREFIX="emias"
|
||||
export PROG_NAME="КИС ЕМИАС"
|
||||
export BASE_PFX="none"
|
||||
|
||||
@@ -33,7 +33,3 @@ else
|
||||
fatal "Не найден файл архива для $PROG_NAME. Перезапустите по примеру:
|
||||
winehelper install $1 \"/путь/до/архива\""
|
||||
fi
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# info_ru: Это мощный, современный инструмент быстрой и качественной разработки АСУТП
|
||||
########################################################################
|
||||
export WH_WINE_USE="wine-10.0-alt-tkg-i586"
|
||||
export WINEPREFIX="scada-krug-2000"
|
||||
export PROG_NAME="SCADA КРУГ 2000"
|
||||
export PROG_ICON="krugtaskmanager"
|
||||
export BASE_PFX="scadakrug2k_pfx_v01"
|
||||
export WH_WINDOWS_VER="10"
|
||||
export WINEARCH="win32"
|
||||
export INSTALL_DLL="dotnet48 vcrun6 vcrun2008 vcrun2022 d3dcompiler_47 mdac28 corefonts riched20 wsh57"
|
||||
export USE_RENDERER="proton"
|
||||
|
||||
if [[ -f "$2" ]] ; then
|
||||
prepair_wine
|
||||
|
||||
kill_autostart "KrugTaskManager.exe" &
|
||||
wine_run_install "$2" /quiet
|
||||
|
||||
WIN_FILE_EXEC="$DRIVE_C/Program Files/Krug2000/Bin/KrugTaskManager.exe"
|
||||
create_desktop "Менеджер задач" "$WIN_FILE_EXEC" "$PROG_ICON"
|
||||
else
|
||||
fatal "Не найден файл установки для $PROG_NAME. Перезапустите по примеру:
|
||||
winehelper install $1 \"/путь/до/установочного_файла\""
|
||||
fi
|
||||
@@ -0,0 +1,9 @@
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "W", "F", "I", "ISC"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
file="sha256sum.list"
|
||||
|
||||
if [[ ! -f "$file" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
awk '
|
||||
/^#/ { next }
|
||||
NF == 0 { next }
|
||||
$1 !~ /^[a-f0-9]{64}$/ {
|
||||
print "Invalid hash line: " NR ": " $0
|
||||
exit 1
|
||||
}
|
||||
' "$file"
|
||||
+14
-2
@@ -1,16 +1,23 @@
|
||||
##### WINE WOW64 #####
|
||||
496e8436a57eb6617d7d90f06b9d7d80d12a5604fb676eddedb2eab346d4f71a wine-11.19-alt-tkg-wow64.tar.xz
|
||||
da8a6b1ced50e4563e00494f2dceddd11b8dbeebd80979172a176242a63a06b8 wine-11.18-alt-adem-wow64.tar.xz
|
||||
e99a44d9f302869287cbe699c2800e3ab4b3e1f9501f20fa3b0491c9cfaae1e7 wine-11.12-alt-tkg-wow64.tar.xz
|
||||
90676579fc97b0bdd0cc265b5c728683f8ce8d8998b020ac39d7c26ebf8783d9 wine-11.0-alt-tkg-wow64.tar.xz
|
||||
d8275a94f7538e600b4c82b0693a9ab1ebf0d5f141ea7ec3bc2a0e230b15676b wine-11.0-alt-emias-wow64.tar.xz
|
||||
d86724bd1b3a7a798f221a6a84430d37a3f531014756b7de49c0d8444d748bd7 wine-10.9-alt-tflex-wow64.tar.xz
|
||||
df5509e1f04424d41d734a255acd3695ac1167b73e52189b97b8acc9084f7fa6 wine-10.0-alt-scadoffice-wow64.tar.xz
|
||||
1f5214b5070066e6868da4f8961506e1d80a00470e5a135945386421a4ca02df wine-10.0.2-alt-scadoffice-wow64.tar.xz
|
||||
714687110724f4772d4028fb340ac1c8f9a32f0454c76822c967af9ef06844eb wine-10.0-alt-tkg-wow64.tar.xz
|
||||
4826028ddd49cc6ec2760be8dbb8ff2353a58323a87c846a8667e41e90851ea1 wine-9.0-alt-tkg-wow64.tar.xz
|
||||
|
||||
##### WINE AMD64 #####
|
||||
d1bfda024138f6e83ee531df513e6ebebb7aee1e40ce3f5cd3a4d0631107e482 wine-11.12-alt-tkg-amd64.tar.xz
|
||||
7a735f1e00173d8d3e3727aab927c4eb86aca018f39a4e8dfc1936909b8b9654 wine-11.0-alt-tkg-amd64.tar.xz
|
||||
2f3aa24fcb52a12bac35a77bf088aa8308e2e0ac3bf89f9b3f9e8e3c7090bbe8 wine-10.0-alt-tkg-amd64.tar.xz
|
||||
4251e75b410268fca9dabc168f838a56f5f02b970c6d4250763da8e791e8880d wine-9.0-alt-tkg-amd64.tar.xz
|
||||
|
||||
##### WINE I586 #####
|
||||
d9210f9be6ccc8be38d5d201aa06a924eeb6079f062dd4bad22da5e3eedeb630 wine-11.12-alt-sk2000-i586.tar.xz
|
||||
b660de1ce16fc90f54997e6cf30d9cf92daa932f5ef14ba8bd7cd5d7a4fa25af wine-11.12-alt-tkg-i586.tar.xz
|
||||
b082dc92c0ed96366353b8a9f62b126276d2476be85d2b37d47a886771a808b6 wine-11.0-alt-tkg-i586.tar.xz
|
||||
d63ae109e2495d3817172146107d9ddfd0b63a0f76b4d575279ae6b642522795 wine-10.0-alt-tkg-i586.tar.xz
|
||||
9a3160ae815b2dd008f67bf230b4e440c6b341d770b7c2a4b1a2c50283c7a895 wine-9.0-alt-tkg-i586.tar.xz
|
||||
@@ -187,7 +194,7 @@ ef7e8f1ba785d48e4ea287feed5b79bd630d423e59efadb43da9653adefef218 ais-lpu-client
|
||||
70a4cbce1b600434b4d5f241ee90dafb15f7b5f821bc1594e728493febaf017b ksamu_pfx_x64_v03.tar.xz
|
||||
# create with wine-10.18.1-alt1-wow64 (universal user: xuser)
|
||||
# winetricks corefonts tahoma msxml6 msxml4 msxml3 riched30 msls31 riched20 msftedit richtx32 fontsmooth=gray
|
||||
# + manuall installed riched32
|
||||
# + manually installed riched32
|
||||
|
||||
5971788003a5337c23003ad0944c59d549e025c5dc10c19d2e0ec9e7dc04819f ppdgr2_pfx_x64_v01.tar.xz
|
||||
# create with wine-10.18.1-alt1-wow64 (universal user: xuser)
|
||||
@@ -218,6 +225,11 @@ d16c300b5451e4fad6eeebeca0949367a7bdf1cb1878fc51f0f9dee33744d3a7 emias_pfx_x64_
|
||||
# create with wine-10.18.1-alt1-wow64 (universal user: xuser)
|
||||
# winetricks msxml6 gdiplus vcrun6sp6 vcrun2005 ucrtbase2019 vcrun2019 d3dx9 d3dx11_43 d3dx11_42 d3dcompiler_42 d3dcompiler_43 d3dcompiler_46 d3dcompiler_47 dotnet40 dotnet48 riched20 riched30 msls31 richtx32
|
||||
|
||||
9b7a241374c27ea341294053d817722e16a5e799acce08f285e62b0f60b6547f adem_pfx_x64_v02.tar.xz
|
||||
# create with wine-11.18-alt-adem-wow64 (universal user: xuser)
|
||||
# winetricks dotnet40 dotnet48 vcrun2008 vcrun2010 vcrun2012 vcrun2022 corefonts
|
||||
# + jet40
|
||||
|
||||
##### ADDONS #####
|
||||
# addons with ODBC, SSH, *.reg
|
||||
0f4ef434df07bc338ae308af44330590eaa1d9c94b64850514e55b960642d0eb scadoffice_addons_v02.tar.xz
|
||||
+2
-2
@@ -95,7 +95,7 @@ if [[ -n "$UPDATE_VER" ]]; then
|
||||
if try_download "https://data.nalog.ru/files/nalul/${UPDATE_URL_PATH}/NalogUL${UPDATE_VER}.msi" "${AUTOINSTALL_UPDATE}"; then
|
||||
wine_run_install "${AUTOINSTALL_UPDATE}" /quiet /norestart
|
||||
try_remove_file "${AUTOINSTALL_UPDATE}"
|
||||
|
||||
|
||||
# После обновления может появиться новая версия печати
|
||||
PRINT_INSTALLER="$(find "$DRIVE_C/npul/INPUTDOC" -name "Печать НД с PDF417*.msi" | tail -n 1)"
|
||||
if [[ -n "$PRINT_INSTALLER" ]]; then
|
||||
@@ -106,7 +106,7 @@ if [[ -n "$UPDATE_VER" ]]; then
|
||||
if try_download "https://data.nalog.ru/files/nalul/${UPDATE_URL_PATH}/NalogUL${UPDATE_VER}.exe" "${AUTOINSTALL_UPDATE}"; then
|
||||
wine_run_install "${AUTOINSTALL_UPDATE}" /VERYSILENT /NORESTART
|
||||
try_remove_file "${AUTOINSTALL_UPDATE}"
|
||||
|
||||
|
||||
# После обновления может появиться новая версия печати
|
||||
PRINT_INSTALLER="$(find "$DRIVE_C/npul/INPUTDOC" -name "Печать НД с PDF417*.msi" | tail -n 1)"
|
||||
if [[ -n "$PRINT_INSTALLER" ]]; then
|
||||
|
||||
+10
-8
@@ -2,7 +2,7 @@
|
||||
# info_ru: SCAD Office — это программный комплекс для расчёта строительных конструкций.
|
||||
########################################################################
|
||||
export PROG_URL="https://scadoffice.ru"
|
||||
export WH_WINE_USE="wine-10.0-alt-scadoffice-wow64"
|
||||
export WH_WINE_USE="wine-10.0.2-alt-scadoffice-wow64"
|
||||
export WINEPREFIX="scadoffice"
|
||||
export PROG_NAME="SCAD Office"
|
||||
export PROG_ICON="scadoffice"
|
||||
@@ -33,13 +33,6 @@ if [[ -n $2 ]] ; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if try_download "https://scadhelp.ru/files/10/download" "${AUTOINSTALL_EXE}" ; then
|
||||
create_new_dir "$DRIVE_C/SDATA"
|
||||
create_new_dir "$DRIVE_C/SWORK"
|
||||
wine_run_install "$AUTOINSTALL_EXE"
|
||||
# try_remove_file "$AUTOINSTALL_EXE"
|
||||
fi
|
||||
|
||||
print_info "Установка дополнительных компонентов..."
|
||||
|
||||
ADDONS_PACK="${WH_TMP_DIR}/$(basename "$SCADOFFICE_ADDONS_URL")"
|
||||
@@ -65,6 +58,15 @@ if try_download "$SCADOFFICE_ADDONS_URL" "$ADDONS_PACK" ; then
|
||||
try_remove_dir "$ADDONS_PATH"
|
||||
fi
|
||||
|
||||
|
||||
if try_download "https://scadhelp.ru/files/10/download" "${AUTOINSTALL_EXE}" ; then
|
||||
create_new_dir "$DRIVE_C/SDATA"
|
||||
create_new_dir "$DRIVE_C/SWORK"
|
||||
wine_run_install "$AUTOINSTALL_EXE"
|
||||
"$WINESERVER" -k
|
||||
# try_remove_file "$AUTOINSTALL_EXE"
|
||||
fi
|
||||
|
||||
# Определение всех программ, значков и исполняемых файлов
|
||||
declare -a PROG_NAME_All=("Арбат" "Вест" "Глобальные настройки" "Декор" "Дискретная арматура" "Запрос" "Камин" "КоКон" "Комета" "Конструктор сечений" "Консул" "Конфигуратор лицензий" "Кристалл" "Кросс" "Куст" "Магнум" "Монолит" "Откос" "Пастернак" "Преобразование ед. измерений" "Cортамент металлопроката" "Расчет по формуле" "Редактор акселерограмм" "Редактор динамичности" "Редактор материалов" "Тонус" "Эквивалентное сечение" "SCAD++")
|
||||
|
||||
|
||||
+962
-703
File diff suppressed because it is too large.
Load diff
+556
-3126
File diff suppressed because it is too large.
Load diff
@@ -7,66 +7,7 @@ GUI пакет для winehelper.
|
||||
- services: Бизнес-логика (кэширование, установка и т.д.)
|
||||
- parsers: Парсеры (sha256sum.list, winetricks output)
|
||||
- utils: Утилиты (обработчики логов и т.д.)
|
||||
|
||||
Реэкспортов нет: импорты — только абсолютные из конкретных модулей
|
||||
(например, from winehelperqt.services.prefix_manager import PrefixManager).
|
||||
"""
|
||||
|
||||
# Parsers
|
||||
from winehelperqt.parsers.sha256_parser import Sha256Parser
|
||||
from winehelperqt.parsers.winetricks_parser import WinetricksOutputParser
|
||||
from winehelperqt.parsers.script_parser import ScriptParser
|
||||
|
||||
# Utils
|
||||
from winehelperqt.utils.log_handler import LogProgressHandler, MultiLogHandler
|
||||
from winehelperqt.utils.cache_service import CacheService, FileCacheService
|
||||
|
||||
# Services
|
||||
from winehelperqt.services.winetricks_service import WinetricksCacheService
|
||||
from winehelperqt.services.winetricks_service import WinetricksLogReader
|
||||
from winehelperqt.services.prefix_manager import PrefixManager
|
||||
from winehelperqt.services.process_handler import ProcessHandler
|
||||
from winehelperqt.services.process_handler import CommandRunner
|
||||
from winehelperqt.services.desktop_file_manager import DesktopFileManager
|
||||
from winehelperqt.services.application_installer import ApplicationInstaller
|
||||
from winehelperqt.services.script_manager import ScriptManager
|
||||
from winehelperqt.services.script_manager import ScriptInfo
|
||||
from winehelperqt.services.installation_manager import InstallationManager
|
||||
from winehelperqt.services.app_launcher import AppLauncher
|
||||
from winehelperqt.services.settings_manager import SettingsManager
|
||||
|
||||
# Widgets
|
||||
from winehelperqt.widgets.icon_manager import IconManager
|
||||
|
||||
# Dialogs
|
||||
from winehelperqt.dialogs.winetricks_manager import WinetricksManagerDialog
|
||||
|
||||
__all__ = [
|
||||
# Parsers
|
||||
'Sha256Parser',
|
||||
'WinetricksOutputParser',
|
||||
'ScriptParser',
|
||||
|
||||
# Utils
|
||||
'LogProgressHandler',
|
||||
'MultiLogHandler',
|
||||
'CacheService',
|
||||
'FileCacheService',
|
||||
|
||||
# Services
|
||||
'WinetricksCacheService',
|
||||
'WinetricksLogReader',
|
||||
'PrefixManager',
|
||||
'ProcessHandler',
|
||||
'CommandRunner',
|
||||
'DesktopFileManager',
|
||||
'ApplicationInstaller',
|
||||
'ScriptManager',
|
||||
'ScriptInfo',
|
||||
'InstallationManager',
|
||||
'AppLauncher',
|
||||
'SettingsManager',
|
||||
|
||||
# Widgets
|
||||
'IconManager',
|
||||
|
||||
# Dialogs
|
||||
'WinetricksManagerDialog',
|
||||
]
|
||||
@@ -1,30 +1,3 @@
|
||||
"""
|
||||
Диалоги для winehelper GUI.
|
||||
"""
|
||||
from winehelperqt.dialogs.base_version_dialog import (
|
||||
BaseVersionSelectionDialog,
|
||||
WineVersionSelectionDialogBase,
|
||||
ComponentVersionSelectionDialogBase,
|
||||
)
|
||||
from winehelperqt.dialogs.winetricks_manager import WinetricksManagerDialog
|
||||
from winehelperqt.dialogs.wine_version import WineVersionSelectionDialog
|
||||
from winehelperqt.dialogs.component_version import ComponentVersionSelectionDialog
|
||||
from winehelperqt.dialogs.create_prefix import CreatePrefixDialog
|
||||
from winehelperqt.dialogs.file_associations import FileAssociationsDialog
|
||||
from winehelperqt.dialogs.settings import AdditionalSettingsDialog, AppAdditionalSettingsDialog
|
||||
|
||||
__all__ = [
|
||||
# Base classes
|
||||
'BaseVersionSelectionDialog',
|
||||
'WineVersionSelectionDialogBase',
|
||||
'ComponentVersionSelectionDialogBase',
|
||||
|
||||
# Dialogs
|
||||
'WinetricksManagerDialog',
|
||||
'WineVersionSelectionDialog',
|
||||
'ComponentVersionSelectionDialog',
|
||||
'CreatePrefixDialog',
|
||||
'FileAssociationsDialog',
|
||||
'AdditionalSettingsDialog',
|
||||
'AppAdditionalSettingsDialog',
|
||||
]
|
||||
@@ -2,19 +2,32 @@
|
||||
Базовые классы для диалогов выбора версий.
|
||||
Устраняет дублирование кода между WineVersionSelectionDialog и ComponentVersionSelectionDialog.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QGridLayout, QScrollArea,
|
||||
QTabWidget, QLineEdit, QPushButton, QLabel, QWidget, QMessageBox
|
||||
)
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.parsers.sha256_parser import Sha256Parser
|
||||
from winehelperqt.qt_compat import (
|
||||
QDialog,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QProcess,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
Qt,
|
||||
QTabWidget,
|
||||
QTimer,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from winehelperqt.utils.environment import EnvironmentConfig
|
||||
from winehelperqt.utils.grid_utils import GridUtils
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class BaseVersionSelectionDialog(QDialog):
|
||||
@@ -25,15 +38,37 @@ class BaseVersionSelectionDialog(QDialog):
|
||||
- Поле поиска с фильтрацией
|
||||
- Сетка кнопок с версиями
|
||||
- Базовая логика выбора версии
|
||||
|
||||
Построение UI двухфазное: конечный наследник обязан вызвать
|
||||
self._setup_ui() в конце своего __init__, когда установлены все поля,
|
||||
необходимые виртуальным методам (_create_version_container, populate_ui).
|
||||
"""
|
||||
|
||||
NUM_COLUMNS = 3
|
||||
|
||||
version_selected = pyqtSignal(str)
|
||||
# Кэш версии системного Wine на время сессии.
|
||||
# None — версия ещё не определена; "" — определить не удалось
|
||||
# (кэшируем и неудачу, чтобы не запускать wine --version при каждом открытии диалога);
|
||||
# непустая строка — определённая версия.
|
||||
_system_wine_version_cache = None
|
||||
|
||||
@classmethod
|
||||
def get_system_wine_version(cls):
|
||||
"""Возвращает версию системного Wine (None — ещё не определена)."""
|
||||
return cls._system_wine_version_cache
|
||||
|
||||
@classmethod
|
||||
def set_system_wine_version(cls, version):
|
||||
"""Запоминает результат определения версии системного Wine (включая неудачу)."""
|
||||
cls._system_wine_version_cache = version or ""
|
||||
|
||||
def __init__(self, title, parent=None, min_width=600, min_height=400):
|
||||
"""
|
||||
Инициализирует базовый диалог выбора версий.
|
||||
|
||||
Не строит UI: виртуальные методы (_create_version_container,
|
||||
load_versions, populate_ui) зависят от полей наследников, поэтому
|
||||
_setup_ui() вызывается конечным классом в конце своего __init__.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
|
||||
@@ -46,15 +81,13 @@ class BaseVersionSelectionDialog(QDialog):
|
||||
self.search_edit = None
|
||||
self.main_layout = None
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _get_sha256_path(self):
|
||||
"""Возвращает путь к файлу sha256sum.list."""
|
||||
data_path = EnvironmentConfig.DATA_PATH
|
||||
if data_path:
|
||||
return os.path.join(data_path, "sha256sum.list")
|
||||
# Фолбэк: ищем рядом с этим файлом (для отладки)
|
||||
return os.path.join(os.path.dirname(__file__), '../../sha256sum.list')
|
||||
# Фолбэк: явно разрешаем путь из расположения пакета
|
||||
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "sha256sum.list"))
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Создает базовый UI диалога."""
|
||||
@@ -77,71 +110,14 @@ class BaseVersionSelectionDialog(QDialog):
|
||||
|
||||
def filter_versions(self):
|
||||
"""Фильтрует версии по тексту поиска."""
|
||||
search_text = self.search_edit.text().lower()
|
||||
|
||||
visible_buttons = []
|
||||
for btn_widget in self.buttons:
|
||||
if search_text in btn_widget.text().lower():
|
||||
visible_buttons.append(btn_widget)
|
||||
|
||||
for btn_widget in self.buttons:
|
||||
btn_widget.setVisible(False)
|
||||
|
||||
for i, btn_widget in enumerate(visible_buttons):
|
||||
row, col = divmod(i, self.NUM_COLUMNS)
|
||||
if hasattr(self, 'grid_layout'):
|
||||
self.grid_layout.addWidget(btn_widget, row, col)
|
||||
btn_widget.setVisible(True)
|
||||
grid_layout = getattr(self, "grid_layout", None)
|
||||
GridUtils.filter_buttons(self.buttons, self.search_edit.text(), grid_layout, self.NUM_COLUMNS)
|
||||
|
||||
def on_version_selected(self, version_name):
|
||||
"""Обрабатывает выбор версии."""
|
||||
self.selected_version = version_name
|
||||
self.version_selected.emit(version_name)
|
||||
self.accept()
|
||||
|
||||
def get_selected_version(self):
|
||||
"""Возвращает выбранную версию."""
|
||||
return self.selected_version
|
||||
|
||||
@staticmethod
|
||||
def create_grid_buttons(
|
||||
grid_layout,
|
||||
versions_list,
|
||||
on_click_callback,
|
||||
start_row=0,
|
||||
num_columns=3,
|
||||
is_download_tab=False,
|
||||
version_filter=None
|
||||
):
|
||||
"""Создает сетку кнопок для версий."""
|
||||
buttons = []
|
||||
row, col = start_row, 0
|
||||
|
||||
for version_data in versions_list:
|
||||
if isinstance(version_data, tuple):
|
||||
display_name, value_name = version_data
|
||||
else:
|
||||
display_name = value_name = version_data
|
||||
|
||||
btn = QPushButton(display_name)
|
||||
btn.clicked.connect(lambda checked, vn=value_name: on_click_callback(vn))
|
||||
|
||||
if version_filter and not is_download_tab:
|
||||
should_disable = version_filter(value_name)
|
||||
if should_disable:
|
||||
btn.setEnabled(False)
|
||||
btn.setToolTip("Эта версия несовместима с выбранной архитектурой.")
|
||||
|
||||
grid_layout.addWidget(btn, row, col)
|
||||
buttons.append(btn)
|
||||
|
||||
col += 1
|
||||
if col >= num_columns:
|
||||
col = 0
|
||||
row += 1
|
||||
|
||||
return buttons
|
||||
|
||||
|
||||
class WineVersionSelectionDialogBase(BaseVersionSelectionDialog):
|
||||
"""
|
||||
@@ -155,13 +131,11 @@ class WineVersionSelectionDialogBase(BaseVersionSelectionDialog):
|
||||
self.architecture = architecture
|
||||
self.wine_versions_data = {}
|
||||
self.system_wine_display_name = "Системная версия"
|
||||
self.system_wine_available = True
|
||||
self.selected_display_text = None
|
||||
|
||||
super().__init__(
|
||||
title=f"Выбор версии Wine для {architecture} префикса",
|
||||
parent=parent,
|
||||
min_width=900,
|
||||
min_height=500
|
||||
title=f"Выбор версии Wine для {architecture} префикса", parent=parent, min_width=900, min_height=500
|
||||
)
|
||||
|
||||
def _create_version_container(self):
|
||||
@@ -177,7 +151,7 @@ class WineVersionSelectionDialogBase(BaseVersionSelectionDialog):
|
||||
loading_widget = QWidget()
|
||||
loading_layout = QVBoxLayout(loading_widget)
|
||||
status_label = QLabel("Загрузка, пожалуйста, подождите...")
|
||||
status_label.setAlignment(Qt.AlignCenter)
|
||||
status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
loading_layout.addWidget(status_label)
|
||||
self.main_tabs.addTab(loading_widget, "Загрузка...")
|
||||
self.main_tabs.setEnabled(False)
|
||||
@@ -193,11 +167,7 @@ class WineVersionSelectionDialogBase(BaseVersionSelectionDialog):
|
||||
self.wine_versions_data = Sha256Parser.parse_wine_versions(sha256_path)
|
||||
|
||||
if not self.wine_versions_data:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Ошибка",
|
||||
f"Файл с версиями не найден или пуст:\n{sha256_path}"
|
||||
)
|
||||
QMessageBox.warning(self, "Ошибка", f"Файл с версиями не найден или пуст:\n{sha256_path}")
|
||||
|
||||
def _get_installed_versions(self):
|
||||
"""Возвращает список локально установленных версий Wine."""
|
||||
@@ -207,35 +177,74 @@ class WineVersionSelectionDialogBase(BaseVersionSelectionDialog):
|
||||
return Sha256Parser.get_installed_versions(dist_path)
|
||||
|
||||
def _get_system_wine_version(self):
|
||||
"""Получает версию системного Wine."""
|
||||
if shutil.which('wine'):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['wine', '--version'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
encoding='utf-8'
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except (FileNotFoundError, subprocess.CalledProcessError) as e:
|
||||
print(f"Не удалось получить версию системного wine: {e}")
|
||||
"""
|
||||
Получает версию системного Wine (с кэшированием на время сессии).
|
||||
|
||||
Определение выполняется асинхронно через QProcess, чтобы открытие
|
||||
диалога не блокировало GUI на время работы wine --version; по готовности
|
||||
текст кнопки системной версии обновляется на вкладке «Установленные версии Wine».
|
||||
|
||||
Returns:
|
||||
Строка с версией; "Системная версия", если версию определить
|
||||
не удалось; None, если Wine не установлен в системе.
|
||||
"""
|
||||
cached = BaseVersionSelectionDialog.get_system_wine_version()
|
||||
if cached is not None:
|
||||
return cached or "Системная версия"
|
||||
|
||||
if not shutil.which("wine"):
|
||||
logger.debug("Системный wine не найден в системе")
|
||||
return None
|
||||
|
||||
self._probe_system_wine_version()
|
||||
return "Системная версия"
|
||||
|
||||
def _is_version_compatible(self, version_name):
|
||||
"""Проверяет совместимость версии с выбранной архитектурой."""
|
||||
is_win64_prefix = self.architecture == "win64"
|
||||
is_win32_prefix = self.architecture == "win32"
|
||||
re_32bit_version = re.compile(r'i[3-6]86|i586')
|
||||
def _probe_system_wine_version(self):
|
||||
"""Запускает асинхронное определение версии системного Wine (без блокировки GUI)."""
|
||||
if BaseVersionSelectionDialog.get_system_wine_version() is not None:
|
||||
# Версию уже определил другой диалог или прогрев при старте GUI:
|
||||
# обновим кнопку из кэша на следующем тике событий
|
||||
QTimer.singleShot(0, self._update_system_wine_button_text)
|
||||
return
|
||||
|
||||
if is_win64_prefix and re_32bit_version.search(version_name):
|
||||
return False
|
||||
proc = QProcess(self)
|
||||
proc.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
|
||||
|
||||
if is_win32_prefix:
|
||||
if version_name == "system" or 'wow64' in version_name:
|
||||
return False
|
||||
def on_finished(exit_code, _exit_status):
|
||||
version = ""
|
||||
try:
|
||||
if exit_code == 0:
|
||||
output = proc.readAllStandardOutput().data().decode("utf-8", errors="ignore").strip()
|
||||
version = output.splitlines()[0].strip() if output else ""
|
||||
except RuntimeError:
|
||||
pass
|
||||
# Кэшируем и неудачу (""), чтобы повторные открытия диалога
|
||||
# не запускали новые процессы wine --version
|
||||
BaseVersionSelectionDialog.set_system_wine_version(version)
|
||||
if version:
|
||||
logger.debug("Системный wine: %s", version)
|
||||
self._update_system_wine_button_text()
|
||||
else:
|
||||
logger.warning("Не удалось определить версию системного wine")
|
||||
|
||||
return True
|
||||
def on_error(_error):
|
||||
# Не перезаписываем версию, если её уже определил параллельный запрос
|
||||
if BaseVersionSelectionDialog.get_system_wine_version() is None:
|
||||
BaseVersionSelectionDialog.set_system_wine_version("")
|
||||
|
||||
proc.finished.connect(on_finished)
|
||||
proc.errorOccurred.connect(on_error)
|
||||
proc.start("wine", ["--version"])
|
||||
|
||||
def _update_system_wine_button_text(self):
|
||||
"""Обновляет текст кнопки системной версии после асинхронного определения версии."""
|
||||
version = BaseVersionSelectionDialog.get_system_wine_version()
|
||||
if not version:
|
||||
return
|
||||
for btn in getattr(self, "installed_buttons", []):
|
||||
if getattr(btn, "value_name", None) == "system":
|
||||
btn.setText(f"{version} (Системный)")
|
||||
break
|
||||
|
||||
def populate_ui(self):
|
||||
"""Заполняет UI версиями Wine."""
|
||||
@@ -247,13 +256,7 @@ class ComponentVersionSelectionDialogBase(BaseVersionSelectionDialog):
|
||||
Базовый класс для диалога выбора версии компонента (DXVK, VKD3D).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
component_group,
|
||||
title,
|
||||
parent=None,
|
||||
add_extra_options=True
|
||||
):
|
||||
def __init__(self, component_group, title, parent=None, add_extra_options=True):
|
||||
"""
|
||||
Инициализирует диалог выбора версии компонента.
|
||||
"""
|
||||
@@ -261,25 +264,20 @@ class ComponentVersionSelectionDialogBase(BaseVersionSelectionDialog):
|
||||
self.versions_data = []
|
||||
self.add_extra_options = add_extra_options
|
||||
|
||||
super().__init__(
|
||||
title=title,
|
||||
parent=parent,
|
||||
min_width=700,
|
||||
min_height=400
|
||||
)
|
||||
super().__init__(title=title, parent=parent, min_width=700, min_height=400)
|
||||
|
||||
def _create_version_container(self):
|
||||
"""Создает контейнер для версий компонента."""
|
||||
self.scroll_area = QScrollArea()
|
||||
self.scroll_area.setWidgetResizable(True)
|
||||
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.main_layout.addWidget(self.scroll_area)
|
||||
|
||||
scroll_content = QWidget()
|
||||
self.scroll_area.setWidget(scroll_content)
|
||||
|
||||
self.grid_layout = QGridLayout(scroll_content)
|
||||
self.grid_layout.setAlignment(Qt.AlignTop)
|
||||
self.grid_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
self.load_versions(start_row=0)
|
||||
|
||||
@@ -311,10 +309,7 @@ class ComponentVersionSelectionDialogBase(BaseVersionSelectionDialog):
|
||||
def _parse_sha256_list(self):
|
||||
"""Парсит sha256sum.list для получения списка версий компонента."""
|
||||
sha256_path = self._get_sha256_path()
|
||||
self.versions_data = Sha256Parser.parse_component_versions(
|
||||
sha256_path,
|
||||
self.component_group
|
||||
)
|
||||
self.versions_data = Sha256Parser.parse_component_versions(sha256_path, self.component_group)
|
||||
|
||||
def populate_ui(self, start_row):
|
||||
"""Заполняет UI версиями компонента."""
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
from winehelperqt.qt_compat import QDialog, QProcess, QTimer
|
||||
from winehelperqt.utils.dialog_utils import DialogUtils
|
||||
|
||||
|
||||
class CommandDialog(QDialog):
|
||||
"""Модальный диалог для выполнения команд winehelper с защитой от случайного закрытия."""
|
||||
|
||||
TERMINATE_GRACE_MS = 10000
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._process = None
|
||||
# True, если пользователь сам подтвердил прерывание через закрытие окна
|
||||
self.aborted_by_user = False
|
||||
|
||||
def set_process(self, process):
|
||||
self._process = process
|
||||
|
||||
@staticmethod
|
||||
def _process_is_running(process) -> bool:
|
||||
"""Жив ли процесс (False если объект уже удалён)."""
|
||||
try:
|
||||
return process.state() == QProcess.ProcessState.Running
|
||||
except RuntimeError:
|
||||
return False
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self._process is None or not self._process_is_running(self._process):
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if not DialogUtils.confirm(
|
||||
self,
|
||||
"Прервать выполнение?",
|
||||
"Процесс ещё выполняется.\nВы действительно хотите прервать его?",
|
||||
yes_text="Да, прервать",
|
||||
no_text="Нет",
|
||||
):
|
||||
event.ignore()
|
||||
return
|
||||
|
||||
self.aborted_by_user = True
|
||||
proc = self._process
|
||||
self._process = None
|
||||
try:
|
||||
proc.terminate()
|
||||
# Окно закрываем сразу, не дожидаясь смерти процесса:
|
||||
# если процесс игнорирует SIGTERM, через TERMINATE_GRACE_MS его убьём
|
||||
QTimer.singleShot(
|
||||
self.TERMINATE_GRACE_MS,
|
||||
lambda: proc.kill() if self._process_is_running(proc) else None,
|
||||
)
|
||||
except RuntimeError:
|
||||
pass
|
||||
event.accept()
|
||||
@@ -2,6 +2,7 @@
|
||||
Диалог для выбора версии компонента (DXVK, VKD3D).
|
||||
|
||||
"""
|
||||
|
||||
from functools import partial
|
||||
|
||||
from winehelperqt.dialogs.base_version_dialog import ComponentVersionSelectionDialogBase
|
||||
@@ -25,22 +26,19 @@ class ComponentVersionSelectionDialog(ComponentVersionSelectionDialogBase):
|
||||
"""
|
||||
self.installed_version = installed_version
|
||||
super().__init__(
|
||||
component_group=component_group,
|
||||
title=title,
|
||||
parent=parent,
|
||||
add_extra_options=add_extra_options
|
||||
component_group=component_group, title=title, parent=parent, add_extra_options=add_extra_options
|
||||
)
|
||||
|
||||
# UI строим в конце: к этому моменту установлены поля всех уровней
|
||||
self._setup_ui()
|
||||
|
||||
def populate_ui(self, start_row):
|
||||
"""Заполняет UI кнопками версий."""
|
||||
versions = sort_versions(self.versions_data, reverse=True)
|
||||
row, col = start_row, 0
|
||||
for version_name in versions:
|
||||
is_installed = version_name == self.installed_version
|
||||
btn = VersionButtonWidget(
|
||||
version_name, version_name,
|
||||
is_installed=is_installed, show_icon=True
|
||||
)
|
||||
btn = VersionButtonWidget(version_name, version_name, is_installed=is_installed, show_icon=True)
|
||||
btn.clicked.connect(partial(self.on_version_selected, version_name))
|
||||
self.grid_layout.addWidget(btn, row, col)
|
||||
self.buttons.append(btn)
|
||||
@@ -49,23 +47,6 @@ class ComponentVersionSelectionDialog(ComponentVersionSelectionDialogBase):
|
||||
col = 0
|
||||
row += 1
|
||||
|
||||
def filter_versions(self):
|
||||
"""Фильтрует видимость кнопок версий и перестраивает сетку."""
|
||||
search_text = self.search_edit.text().lower()
|
||||
|
||||
visible_buttons = []
|
||||
for btn_widget in self.buttons:
|
||||
if search_text in btn_widget.text().lower():
|
||||
visible_buttons.append(btn_widget)
|
||||
|
||||
for btn_widget in self.buttons:
|
||||
btn_widget.setVisible(False)
|
||||
|
||||
for i, btn_widget in enumerate(visible_buttons):
|
||||
row, col = divmod(i, self.NUM_COLUMNS)
|
||||
self.grid_layout.addWidget(btn_widget, row, col)
|
||||
btn_widget.setVisible(True)
|
||||
|
||||
def on_version_selected(self, version_name):
|
||||
"""Обрабатывает выбор версии."""
|
||||
self.selected_version = version_name
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Диалог создания лога запуска программы с выбором каналов WINEDEBUG.
|
||||
"""
|
||||
|
||||
from winehelperqt.dialogs.winedebug_base import CREATE_LOG_CHANNELS_KEY, WinedebugChannelsDialog
|
||||
|
||||
|
||||
class CreateLogDialog(WinedebugChannelsDialog):
|
||||
"""Диалог для запуска приложения с созданием лога."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(
|
||||
title="Создание лога",
|
||||
description=(
|
||||
"Приложение будет запущено в режиме отладки.\n\n"
|
||||
"После закрытия приложения лог будет сохранен в папке 'winehelper_backup_log' "
|
||||
"в вашем домашнем каталоге под именем (пример: prefix_program.log).\n\n"
|
||||
"Укажите каналы отладки WINEDEBUG:"
|
||||
),
|
||||
primary_button_text="Запустить",
|
||||
channels_key=CREATE_LOG_CHANNELS_KEY,
|
||||
parent=parent,
|
||||
)
|
||||
@@ -1,26 +1,39 @@
|
||||
"""
|
||||
Диалог для создания нового префикса.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QPushButton,
|
||||
QLabel, QLineEdit, QRadioButton, QComboBox, QMessageBox, QWidget
|
||||
)
|
||||
from PyQt5.QtGui import QFont
|
||||
|
||||
from winehelperqt.parsers.sha256_parser import Sha256Parser
|
||||
from winehelperqt.dialogs.windows_version import WindowsVersionSelectionDialog
|
||||
from winehelperqt.dialogs.wine_version import WineVersionSelectionDialog
|
||||
from winehelperqt.parsers.sha256_parser import Sha256Parser
|
||||
from winehelperqt.qt_compat import (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from winehelperqt.utils.environment import EnvironmentConfig
|
||||
from winehelperqt.utils.font_utils import app_font
|
||||
|
||||
|
||||
class CreatePrefixDialog(QDialog):
|
||||
"""Диалог для создания нового префикса."""
|
||||
|
||||
PREFIX_NAME_PATTERN = r"^[a-zA-Z0-9_-]+$"
|
||||
PREFIX_NAME_CHARS = r"[^a-zA-Z0-9_-]"
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.parent_gui = parent
|
||||
self.parent_tab = parent
|
||||
self.setWindowTitle("Создание нового префикса")
|
||||
self.setMinimumSize(680, 250)
|
||||
self.setModal(True)
|
||||
@@ -31,6 +44,7 @@ class CreatePrefixDialog(QDialog):
|
||||
self.prepared_prefixes = {}
|
||||
self.selected_wine_version_value = None
|
||||
self.selected_wine_version_display = None
|
||||
self.selected_windows_version = "10"
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
form_layout = QFormLayout()
|
||||
@@ -65,6 +79,7 @@ class CreatePrefixDialog(QDialog):
|
||||
form_layout.addRow("<b>Разрядность:</b>", arch_widget)
|
||||
|
||||
self.prefix_template_selector = QComboBox()
|
||||
self.prefix_template_selector.setFixedWidth(170)
|
||||
self.prefix_template_selector.setToolTip("Выберите шаблон для создания префикса.")
|
||||
form_layout.addRow("<b>Шаблон префикса:</b>", self.prefix_template_selector)
|
||||
|
||||
@@ -80,11 +95,18 @@ class CreatePrefixDialog(QDialog):
|
||||
version_layout.addWidget(select_version_button)
|
||||
form_layout.addRow("<b>Версия Wine:</b>", version_layout)
|
||||
|
||||
self.windows_version_combo = QComboBox()
|
||||
self.windows_version_combo.setFixedWidth(170)
|
||||
for display_name, value in WindowsVersionSelectionDialog.WINDOWS_VERSIONS:
|
||||
self.windows_version_combo.addItem(display_name, value)
|
||||
self.windows_version_combo.setCurrentIndex(self.windows_version_combo.findData(self.selected_windows_version))
|
||||
form_layout.addRow("<b>Версия Windows:</b>", self.windows_version_combo)
|
||||
|
||||
layout.addLayout(form_layout)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
self.create_button = QPushButton("Создать")
|
||||
self.create_button.setFont(QFont('Arial', 11, QFont.Bold))
|
||||
self.create_button.setFont(app_font(11, bold=True))
|
||||
self.create_button.setStyleSheet("background-color: #0078d7; color: white;")
|
||||
self.create_button.setEnabled(False)
|
||||
self.create_button.clicked.connect(self.accept_creation)
|
||||
@@ -100,13 +122,16 @@ class CreatePrefixDialog(QDialog):
|
||||
self.arch_win32_radio.toggled.connect(self.on_architecture_changed)
|
||||
self.prefix_name_edit.textChanged.connect(self.validate_prefix_name)
|
||||
self.wine_version_edit.textChanged.connect(self.update_create_button_state)
|
||||
# Подключаем один раз здесь: при подключении внутри on_architecture_changed
|
||||
# обработчики накапливались бы при каждом переключении радио 32/64-bit
|
||||
self.prefix_template_selector.currentIndexChanged.connect(self._update_template_tooltip)
|
||||
|
||||
self._load_prepared_prefixes()
|
||||
self.on_architecture_changed()
|
||||
|
||||
def _load_prepared_prefixes(self):
|
||||
"""Загружает и парсит шаблоны префиксов из sha256sum.list."""
|
||||
self.prepared_prefixes = {'win32': [], 'win64': []}
|
||||
self.prepared_prefixes = {"win32": [], "win64": []}
|
||||
data_path = EnvironmentConfig.DATA_PATH
|
||||
if not data_path:
|
||||
QMessageBox.warning(self, "Ошибка", "Переменная окружения DATA_PATH не установлена.")
|
||||
@@ -123,7 +148,7 @@ class CreatePrefixDialog(QDialog):
|
||||
"""Открывает диалог выбора версии Wine."""
|
||||
architecture = "win32" if self.arch_win32_radio.isChecked() else "win64"
|
||||
dialog = WineVersionSelectionDialog(architecture, self)
|
||||
if dialog.exec_() == QDialog.Accepted and dialog.selected_version:
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted and dialog.selected_version:
|
||||
self.wine_version_edit.setText(dialog.selected_display_text)
|
||||
self.selected_wine_version_value = dialog.selected_version
|
||||
|
||||
@@ -140,23 +165,26 @@ class CreatePrefixDialog(QDialog):
|
||||
|
||||
for value, description in templates:
|
||||
display_name = "Чистый префикс" if value == "none" else value
|
||||
self.prefix_template_selector.addItem(display_name, userData={'value': value, 'tooltip': description})
|
||||
self.prefix_template_selector.addItem(display_name, userData={"value": value, "tooltip": description})
|
||||
|
||||
self.prefix_template_selector.blockSignals(False)
|
||||
self.prefix_template_selector.currentIndexChanged.connect(
|
||||
lambda i: self.prefix_template_selector.setToolTip(
|
||||
self.prefix_template_selector.itemData(i)['tooltip']
|
||||
)
|
||||
)
|
||||
self.prefix_template_selector.setToolTip(self.prefix_template_selector.itemData(0)['tooltip'])
|
||||
# Сигнал был заблокирован на время заполнения списка — обновляем подсказку вручную
|
||||
self._update_template_tooltip(self.prefix_template_selector.currentIndex())
|
||||
|
||||
def _update_template_tooltip(self, index):
|
||||
"""Обновляет подсказку выпадающего списка под выбранный шаблон."""
|
||||
data = self.prefix_template_selector.itemData(index)
|
||||
if data:
|
||||
self.prefix_template_selector.setToolTip(data["tooltip"])
|
||||
else:
|
||||
self.prefix_template_selector.setToolTip("Выберите шаблон для создания префикса.")
|
||||
|
||||
def validate_prefix_name(self, text):
|
||||
"""Проверяет имя префикса в реальном времени."""
|
||||
valid_pattern = r'^[a-zA-Z0-9_-]*$'
|
||||
if re.match(valid_pattern, text):
|
||||
if re.match(self.PREFIX_NAME_PATTERN, text):
|
||||
self.name_warning_label.setVisible(False)
|
||||
else:
|
||||
cleaned_text = re.sub(r'[^a-zA-Z0-9_-]', '', text)
|
||||
cleaned_text = re.sub(self.PREFIX_NAME_CHARS, "", text)
|
||||
self.prefix_name_edit.blockSignals(True)
|
||||
self.prefix_name_edit.setText(cleaned_text)
|
||||
self.prefix_name_edit.blockSignals(False)
|
||||
@@ -168,9 +196,7 @@ class CreatePrefixDialog(QDialog):
|
||||
"""Включает или выключает кнопку 'Создать'."""
|
||||
name_ok = bool(self.prefix_name_edit.text().strip())
|
||||
version_ok = bool(self.wine_version_edit.text().strip())
|
||||
self.create_button.setEnabled(
|
||||
name_ok and version_ok and not self.name_warning_label.isVisible()
|
||||
)
|
||||
self.create_button.setEnabled(name_ok and version_ok and not self.name_warning_label.isVisible())
|
||||
|
||||
def accept_creation(self):
|
||||
"""Валидирует данные, сохраняет их и закрывает диалог с успехом."""
|
||||
@@ -180,10 +206,11 @@ class CreatePrefixDialog(QDialog):
|
||||
QMessageBox.warning(self, "Ошибка", "Имя префикса не может быть пустым.")
|
||||
return
|
||||
|
||||
if not re.match(r'^[a-zA-Z0-9_-]+$', prefix_name):
|
||||
if not re.match(self.PREFIX_NAME_PATTERN, prefix_name):
|
||||
QMessageBox.warning(
|
||||
self, "Ошибка",
|
||||
"Имя префикса может содержать только латинские буквы, цифры, дефисы и знаки подчеркивания."
|
||||
self,
|
||||
"Ошибка",
|
||||
"Имя префикса может содержать только латинские буквы, цифры, дефисы и знаки подчеркивания.",
|
||||
)
|
||||
return
|
||||
|
||||
@@ -195,8 +222,10 @@ class CreatePrefixDialog(QDialog):
|
||||
self.prefix_name = prefix_name
|
||||
self.wine_arch = "win32" if self.arch_win32_radio.isChecked() else "win64"
|
||||
|
||||
current_index = self.prefix_template_selector.currentIndex()
|
||||
self.base_pfx = self.prefix_template_selector.itemData(current_index)['value']
|
||||
template_data = self.prefix_template_selector.itemData(self.prefix_template_selector.currentIndex())
|
||||
# Список шаблонов может быть пуст (секция PREFIX отсутствует в sha256sum.list)
|
||||
self.base_pfx = template_data["value"] if template_data else "none"
|
||||
self.selected_wine_version_display = self.wine_version_edit.text()
|
||||
self.selected_windows_version = self.windows_version_combo.currentData()
|
||||
|
||||
self.accept()
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Модуль вывода предупреждения о проверке установки КриптоПро в системе.
|
||||
|
||||
Выводит информационное окно со ссылками на официальный сайт КриптоПро
|
||||
и инструкцию по установке КриптоПро в систему.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from winehelperqt.qt_compat import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
QTextBrowser,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
|
||||
class CryptoProWarningDialog(QDialog):
|
||||
"""Диалог предупреждения о проверке установки КриптоПро."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("WineHelper — Предупреждение!")
|
||||
self.setMinimumSize(600, 400)
|
||||
self.setModal(True)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(12)
|
||||
|
||||
self.info_browser = QTextBrowser()
|
||||
self.info_browser.setOpenExternalLinks(True)
|
||||
self.info_browser.setStyleSheet("""
|
||||
QTextBrowser {
|
||||
background-color: #1e1e1e;
|
||||
color: #e4e4e4;
|
||||
border: none;
|
||||
}
|
||||
""")
|
||||
self.info_browser.setHtml(self._build_html_content())
|
||||
layout.addWidget(self.info_browser)
|
||||
|
||||
def _build_html_content(self) -> str:
|
||||
"""Формирует HTML-содержимое окна."""
|
||||
body_style = (
|
||||
"font-family: 'Segoe UI', 'Noto Sans', sans-serif; font-size: 14px; color: #e4e4e4; line-height: 1.55;"
|
||||
)
|
||||
warning_style = "background-color: #3d351f; border-left: 4px solid #ffcc00; padding: 12px 14px; margin: 0;"
|
||||
warning_text_style = "color: #ffd700; font-weight: bold; text-align: center; margin: 0 0 6px 0;"
|
||||
card_style = "padding: 14px; margin: 0;"
|
||||
title_style = "font-size: 15px; font-weight: bold; color: #ffffff; margin: 0 0 10px 0;"
|
||||
paragraph_style = "margin: 0 0 10px 0;"
|
||||
link_style = "color: #7ec7ff;"
|
||||
|
||||
return f"""
|
||||
<html>
|
||||
<body style="{body_style}">
|
||||
<p style="text-align: center; font-size: 20px; font-weight: bold; color: #ffd700; margin: 0 0 8px 0;">
|
||||
Предупреждение!
|
||||
</p>
|
||||
<div style="{warning_style}">
|
||||
<p style="{warning_text_style} margin-bottom: 0;">
|
||||
⚠️ Проверьте правильность установки КриптоПро в системе!
|
||||
</p>
|
||||
</div>
|
||||
<div style="line-height: 18px; font-size: 1px;"> </div>
|
||||
|
||||
<div style="{card_style}">
|
||||
<p style="{title_style}">Официальный сайт КриптоПро</p>
|
||||
<p style="{paragraph_style}">
|
||||
<a style="{link_style}" href="https://cryptopro.ru/products/csp">
|
||||
https://cryptopro.ru/products/csp
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p style="{title_style} margin-top: 14px;">Инструкция установки КриптоПро в систему</p>
|
||||
<p style="{paragraph_style} margin-bottom: 0;">
|
||||
<a style="{link_style}" href="https://www.altlinux.org/CryptoPro">
|
||||
https://www.altlinux.org/CryptoPro
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Точка входа для автономного запуска модуля."""
|
||||
app = QApplication(sys.argv)
|
||||
dialog = CryptoProWarningDialog()
|
||||
dialog.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,10 +1,8 @@
|
||||
"""
|
||||
Диалог для управления ассоциациями файлов (WH_XDG_OPEN).
|
||||
"""
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QLineEdit, QLabel, QDialogButtonBox, QMessageBox
|
||||
)
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
from winehelperqt.qt_compat import QDialog, QDialogButtonBox, QLabel, QLineEdit, QMessageBox, Qt, QVBoxLayout
|
||||
|
||||
|
||||
class FileAssociationsDialog(QDialog):
|
||||
@@ -27,7 +25,7 @@ class FileAssociationsDialog(QDialog):
|
||||
"<b>Пример:</b> <code>pdf docx txt</code>"
|
||||
)
|
||||
info_label.setWordWrap(True)
|
||||
info_label.setTextFormat(Qt.RichText)
|
||||
info_label.setTextFormat(Qt.TextFormat.RichText)
|
||||
layout.addWidget(info_label)
|
||||
|
||||
self.associations_edit = QLineEdit()
|
||||
@@ -37,13 +35,11 @@ class FileAssociationsDialog(QDialog):
|
||||
self.associations_edit.setPlaceholderText("Введите расширения через пробел...")
|
||||
layout.addWidget(self.associations_edit)
|
||||
|
||||
forbidden_label = QLabel(
|
||||
"<small><b>Запрещено использовать:</b> cpl, dll, exe, lnk, msi</small>"
|
||||
)
|
||||
forbidden_label.setTextFormat(Qt.RichText)
|
||||
forbidden_label = QLabel("<small><b>Запрещено использовать:</b> cpl, dll, exe, lnk, msi</small>")
|
||||
forbidden_label.setTextFormat(Qt.TextFormat.RichText)
|
||||
layout.addWidget(forbidden_label)
|
||||
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||
button_box.accepted.connect(self.validate_and_accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
layout.addWidget(button_box)
|
||||
@@ -53,22 +49,20 @@ class FileAssociationsDialog(QDialog):
|
||||
forbidden_extensions = {"cpl", "dll", "exe", "lnk", "msi"}
|
||||
|
||||
input_text = self.associations_edit.text().lower().strip()
|
||||
entered_extensions = {
|
||||
ext.strip() for ext in input_text.split() if ext.strip()
|
||||
}
|
||||
entered_extensions = {ext.strip() for ext in input_text.split() if ext.strip()}
|
||||
|
||||
found_forbidden = entered_extensions.intersection(forbidden_extensions)
|
||||
|
||||
if found_forbidden:
|
||||
msg_box = QMessageBox(self)
|
||||
msg_box.setIcon(QMessageBox.Warning)
|
||||
msg_box.setIcon(QMessageBox.Icon.Warning)
|
||||
msg_box.setWindowTitle("Недопустимые расширения")
|
||||
msg_box.setTextFormat(Qt.RichText)
|
||||
msg_box.setTextFormat(Qt.TextFormat.RichText)
|
||||
msg_box.setText(
|
||||
"Следующие расширения запрещены и не могут быть использованы:<br><br>"
|
||||
f"<b>{', '.join(sorted(list(found_forbidden)))}</b>"
|
||||
)
|
||||
msg_box.exec_()
|
||||
msg_box.exec()
|
||||
return
|
||||
|
||||
self.new_associations = " ".join(sorted(list(entered_extensions)))
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Диалог/виджет глобальных настроек WineHelper GUI.
|
||||
"""
|
||||
|
||||
from winehelperqt.logger import get_logger, setup_logger
|
||||
from winehelperqt.qt_compat import (
|
||||
CURRENT_BINDING,
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QProcess,
|
||||
QPushButton,
|
||||
Qt,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
available_bindings,
|
||||
)
|
||||
from winehelperqt.services.global_settings_service import GlobalSettingsService
|
||||
from winehelperqt.utils.dialog_utils import DialogUtils
|
||||
from winehelperqt.utils.environment import EnvironmentConfig
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GlobalSettingsWidget(QWidget):
|
||||
"""Виджет с глобальными настройками приложения."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.settings_service = GlobalSettingsService()
|
||||
self._setup_ui()
|
||||
self._load_settings()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Создаёт элементы управления."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
layout.setSpacing(15)
|
||||
|
||||
dev_notice_label = QLabel(
|
||||
"Эта вкладка отображается только в режиме разработки (тестовый запуск из репозитория)."
|
||||
)
|
||||
dev_notice_label.setWordWrap(True)
|
||||
dev_notice_label.setStyleSheet("color: #e05000; font-weight: bold; font-size: 13pt;")
|
||||
layout.addWidget(dev_notice_label)
|
||||
|
||||
form_layout = QFormLayout()
|
||||
form_layout.setSpacing(10)
|
||||
|
||||
self.log_level_label = QLabel("Уровень логирования:")
|
||||
self.log_level_label.setToolTip(
|
||||
"debug — максимально подробный вывод\n"
|
||||
"info — обычный вывод\n"
|
||||
"warning — только предупреждения и ошибки\n"
|
||||
"error — только ошибки"
|
||||
)
|
||||
|
||||
self.log_level_combo = QComboBox()
|
||||
self.log_level_combo.setMinimumWidth(200)
|
||||
self.log_level_combo.addItem("Отладка (debug)", "debug")
|
||||
self.log_level_combo.addItem("Информация (info)", "info")
|
||||
self.log_level_combo.addItem("Предупреждения (warning)", "warning")
|
||||
self.log_level_combo.addItem("Ошибки (error)", "error")
|
||||
|
||||
form_layout.addRow(self.log_level_label, self.log_level_combo)
|
||||
|
||||
self.qt_binding_label = QLabel("Qt binding:")
|
||||
self.qt_binding_label.setToolTip(
|
||||
"Библиотека, через которую работает графический интерфейс.\n"
|
||||
"auto — PyQt6, если он установлен, иначе PyQt5 (рекомендуется).\n"
|
||||
"PyQt6/PyQt5 — строго выбранный binding; если он не установлен,\n"
|
||||
"GUI не запустится (вернуться можно переменной WH_QT_BINDING).\n"
|
||||
"Изменение применяется после перезапуска WineHelper."
|
||||
)
|
||||
|
||||
self.qt_binding_combo = QComboBox()
|
||||
self.qt_binding_combo.setMinimumWidth(200)
|
||||
self.qt_binding_combo.addItem("Авто (рекомендуется)", "auto")
|
||||
self.qt_binding_combo.addItem("PyQt6", "pyqt6")
|
||||
self.qt_binding_combo.addItem("PyQt5", "pyqt5")
|
||||
|
||||
installed = available_bindings()
|
||||
combo_model = self.qt_binding_combo.model()
|
||||
for index in range(self.qt_binding_combo.count()):
|
||||
data = self.qt_binding_combo.itemData(index)
|
||||
if data in ("pyqt5", "pyqt6") and data not in installed:
|
||||
item = combo_model.item(index)
|
||||
item.setEnabled(False)
|
||||
self.qt_binding_combo.setItemText(index, f"{self.qt_binding_combo.itemText(index)} (не установлен)")
|
||||
|
||||
form_layout.addRow(self.qt_binding_label, self.qt_binding_combo)
|
||||
|
||||
layout.addLayout(form_layout)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.addStretch()
|
||||
|
||||
self.save_button = QPushButton("Сохранить")
|
||||
self.save_button.setMinimumHeight(32)
|
||||
self.save_button.clicked.connect(self._save_settings)
|
||||
self.save_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
button_layout.addWidget(self.save_button)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def _load_settings(self):
|
||||
"""Загружает текущие настройки в интерфейс."""
|
||||
current_level = self.settings_service.get_log_level()
|
||||
index = self.log_level_combo.findData(current_level)
|
||||
if index >= 0:
|
||||
self.log_level_combo.setCurrentIndex(index)
|
||||
|
||||
current_binding = self.settings_service.get_qt_binding()
|
||||
index = self.qt_binding_combo.findData(current_binding)
|
||||
if index >= 0:
|
||||
self.qt_binding_combo.setCurrentIndex(index)
|
||||
|
||||
def _save_qt_binding(self):
|
||||
"""
|
||||
Сохраняет выбранный Qt binding; при смене фактического binding'а
|
||||
предлагает перезапустить приложение.
|
||||
|
||||
Returns:
|
||||
bool: False если сохранение не удалось (сообщение уже показано).
|
||||
"""
|
||||
new_binding = self.qt_binding_combo.currentData()
|
||||
if new_binding == self.settings_service.get_qt_binding():
|
||||
return True
|
||||
|
||||
if not self.settings_service.set_qt_binding(new_binding):
|
||||
QMessageBox.warning(self, "Ошибка", "Не удалось сохранить Qt binding.")
|
||||
return False
|
||||
|
||||
# Какой binding фактически загрузится при следующем запуске
|
||||
effective = new_binding
|
||||
if effective == "auto":
|
||||
effective = "pyqt6" if "pyqt6" in available_bindings() else "pyqt5"
|
||||
|
||||
if effective == CURRENT_BINDING:
|
||||
return True
|
||||
|
||||
logger.info("Qt binding изменён: %s -> %s (требуется перезапуск)", CURRENT_BINDING, effective)
|
||||
if DialogUtils.confirm(
|
||||
self,
|
||||
"Изменение Qt binding",
|
||||
f"При следующем запуске будет использован {effective}.\n\nПерезапустить WineHelper сейчас?",
|
||||
default_no=False,
|
||||
):
|
||||
self._restart()
|
||||
else:
|
||||
QMessageBox.information(
|
||||
self, "Сохранено", "Новый Qt binding вступит в силу при следующем запуске WineHelper."
|
||||
)
|
||||
return True
|
||||
|
||||
def _restart(self):
|
||||
"""Перезапускает GUI через скрипт-обёртку winehelper.
|
||||
|
||||
Новый процесс стартует с небольшой задержкой, чтобы текущий успел
|
||||
завершиться и освободить сокет single-instance (QLocalServer).
|
||||
"""
|
||||
run_script = EnvironmentConfig.RUN_SCRIPT
|
||||
if not run_script:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Ошибка",
|
||||
"Не удалось определить путь к скрипту winehelper.\nПерезапустите WineHelper вручную.",
|
||||
)
|
||||
return
|
||||
QProcess.startDetached("sh", ["-c", f'sleep 1; exec "{run_script}" gui'])
|
||||
app = QApplication.instance()
|
||||
if app:
|
||||
app.quit()
|
||||
|
||||
def _save_log_level(self):
|
||||
"""Сохраняет и применяет уровень логирования, только если он изменился."""
|
||||
new_level = self.log_level_combo.currentData()
|
||||
if new_level == self.settings_service.get_log_level():
|
||||
return
|
||||
if self.settings_service.set_log_level(new_level):
|
||||
setup_logger(new_level)
|
||||
# Сообщение отправляем на новом уровне: info-сообщение было бы
|
||||
# отфильтровано, если новый уровень warning/error.
|
||||
log_func = {
|
||||
"debug": logger.debug,
|
||||
"info": logger.info,
|
||||
"warning": logger.warning,
|
||||
"error": logger.error,
|
||||
}[new_level]
|
||||
log_func("Уровень логирования изменён на '%s'", new_level)
|
||||
QMessageBox.information(self, "Сохранено", f"Уровень логирования изменён на '{new_level}'.")
|
||||
else:
|
||||
QMessageBox.warning(self, "Ошибка", "Не удалось сохранить уровень логирования.")
|
||||
|
||||
def _save_settings(self):
|
||||
"""Сохраняет настройки; Qt binding — последним, т.к. может инициировать перезапуск."""
|
||||
self._save_log_level()
|
||||
|
||||
if not self._save_qt_binding():
|
||||
return
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Диалог установки приложения с защитой от случайного закрытия во время процесса.
|
||||
|
||||
В отличие от CommandDialog (который при прерывании закрывается сразу),
|
||||
окно установки остаётся открытым до фактического завершения процесса,
|
||||
чтобы пользователь увидел финальное состояние лога.
|
||||
"""
|
||||
|
||||
from winehelperqt.dialogs.command_dialog import CommandDialog
|
||||
from winehelperqt.utils.dialog_utils import DialogUtils
|
||||
|
||||
|
||||
class InstallDialog(CommandDialog):
|
||||
"""Модальный диалог установки приложения с защитой от случайного закрытия."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._abort_callback = None
|
||||
|
||||
def set_abort_callback(self, callback):
|
||||
"""Задаёт callback, вызываемый при подтверждённом прерывании через закрытие окна."""
|
||||
self._abort_callback = callback
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self._process is None or not self._process_is_running(self._process):
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if not DialogUtils.confirm(
|
||||
self,
|
||||
"Прервать установку?",
|
||||
"Установка еще не завершена.\nВы действительно хотите прервать процесс?",
|
||||
yes_text="Да, прервать",
|
||||
no_text="Нет",
|
||||
):
|
||||
event.ignore()
|
||||
return
|
||||
|
||||
if self._abort_callback:
|
||||
self._abort_callback()
|
||||
# Запрещаем закрытие: окно закроется кнопкой «Закрыть» после завершения процесса
|
||||
event.ignore()
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Модуль вывода предупреждения о noexec для /home.
|
||||
|
||||
Выводит информационное окно с инструкциями по устранению проблемы,
|
||||
когда /home примонтирован с флагом noexec и запуск Wine невозможен.
|
||||
"""
|
||||
|
||||
import getpass
|
||||
import sys
|
||||
|
||||
from winehelperqt.qt_compat import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
QTextBrowser,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
|
||||
class NoExecWarningDialog(QDialog):
|
||||
"""Диалог предупреждения о флаге noexec для /home."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("WineHelper — Предупреждение!")
|
||||
self.setMinimumSize(600, 620)
|
||||
self.setModal(True)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(12)
|
||||
|
||||
self.info_browser = QTextBrowser()
|
||||
self.info_browser.setOpenExternalLinks(True)
|
||||
self.info_browser.setStyleSheet("""
|
||||
QTextBrowser {
|
||||
background-color: #1e1e1e;
|
||||
color: #e4e4e4;
|
||||
border: none;
|
||||
}
|
||||
""")
|
||||
self.info_browser.setHtml(self._build_html_content())
|
||||
layout.addWidget(self.info_browser)
|
||||
|
||||
def _build_html_content(self) -> str:
|
||||
"""Формирует HTML-содержимое окна с подстановкой имени пользователя."""
|
||||
username = getpass.getuser()
|
||||
|
||||
body_style = (
|
||||
"font-family: 'Segoe UI', 'Noto Sans', sans-serif; font-size: 14px; color: #e4e4e4; line-height: 1.55;"
|
||||
)
|
||||
warning_style = "background-color: #3d351f; border-left: 4px solid #ffcc00; padding: 12px 14px; margin: 0;"
|
||||
warning_text_style = "color: #ffd700; font-weight: bold; text-align: center; margin: 0 0 6px 0;"
|
||||
card_style = "padding: 14px; margin: 0;"
|
||||
title_style = "font-size: 15px; font-weight: bold; color: #ffffff; margin: 0 0 10px 0;"
|
||||
paragraph_style = "margin: 0 0 10px 0;"
|
||||
pre_style = (
|
||||
"color: #7ee787; font-family: 'JetBrains Mono', 'Consolas', 'Courier New', monospace; "
|
||||
"font-size: 13px; padding: 4px 0; margin: 0 0 12px 0;"
|
||||
)
|
||||
|
||||
return f"""
|
||||
<html>
|
||||
<body style="{body_style}">
|
||||
<p style="text-align: center; font-size: 20px; font-weight: bold; color: #ffd700; margin: 0 0 8px 0;">
|
||||
Предупреждение!
|
||||
</p>
|
||||
<div style="{warning_style}">
|
||||
<p style="{warning_text_style} margin-bottom: 0;">
|
||||
⚠️ /home примонтирован в /etc/fstab с аргументом noexec.<br>
|
||||
Запуск портативной версии wine невозможен из домашнего каталога!
|
||||
Используйте один из двух вариантов решения проблемы.
|
||||
</p>
|
||||
</div>
|
||||
<div style="line-height: 18px; font-size: 1px;"> </div>
|
||||
|
||||
<div style="{card_style}">
|
||||
<p style="{title_style}">1. Снять флаг noexec в /etc/fstab</p>
|
||||
<pre style="{pre_style}">$ su -
|
||||
# apt-get install nano
|
||||
# nano /etc/fstab</pre>
|
||||
<p style="{paragraph_style}">
|
||||
Удаляем флаг <b>noexec</b> напротив <b>/home</b>.<br>
|
||||
<b>Ctrl+O</b> — сохранить изменения,<br>
|
||||
<b>Ctrl+X</b> — выход.<br>
|
||||
Можно использовать любой редактор; nano приведён как пример.
|
||||
</p>
|
||||
|
||||
<p style="{title_style} margin-top: 14px;">Обновляем загрузчик:</p>
|
||||
<pre style="{pre_style}"># update-grub
|
||||
# exit</pre>
|
||||
<p style="{paragraph_style} margin-bottom: 0;">
|
||||
После перезагрузки можно приступать к работе с WineHelper.
|
||||
</p>
|
||||
</div>
|
||||
<div style="line-height: 18px; font-size: 1px;"> </div>
|
||||
|
||||
<div style="{card_style}">
|
||||
<p style="{title_style}">2. Или изменить рабочий каталог для WineHelper</p>
|
||||
<p style="{paragraph_style}">
|
||||
Каталог должен находиться <b>вне /home</b> и иметь права на чтение-запись
|
||||
для пользователя <b>{username}</b>.
|
||||
</p>
|
||||
<p style="{paragraph_style}">Используйте команду:</p>
|
||||
<pre style="{pre_style} margin-bottom: 0;">$ winehelper data-path /путь/к/каталогу</pre>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Точка входа для автономного запуска модуля."""
|
||||
app = QApplication(sys.argv)
|
||||
dialog = NoExecWarningDialog()
|
||||
dialog.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Диалог управления логированием действий на вкладке «Менеджер префиксов».
|
||||
"""
|
||||
|
||||
from winehelperqt.dialogs.winedebug_base import PREFIX_LOG_CHANNELS_KEY, WinedebugChannelsDialog
|
||||
|
||||
|
||||
class PrefixLoggingDialog(WinedebugChannelsDialog):
|
||||
"""Диалог включения/выключения логирования (каналы WINEDEBUG) для вкладки 'Менеджер префиксов'."""
|
||||
|
||||
def __init__(self, logging_enabled=False, parent=None):
|
||||
self._logging_enabled = logging_enabled
|
||||
state_text = "включено" if logging_enabled else "выключено"
|
||||
super().__init__(
|
||||
title="Логирование действий с префиксом",
|
||||
description=(
|
||||
f"Сейчас логирование <b>{state_text}</b>.<br><br>"
|
||||
"При включённом логировании все действия на вкладке 'Менеджер префиксов' "
|
||||
"(запуск файлов и утилит Wine) будут выполняться с указанными каналами WINEDEBUG, "
|
||||
"а лог будет сохраняться в папке 'winehelper_backup_log' в вашем домашнем каталоге."
|
||||
),
|
||||
primary_button_text="Выключить" if logging_enabled else "Включить",
|
||||
channels_key=PREFIX_LOG_CHANNELS_KEY,
|
||||
channels_label="Укажите каналы отладки WINEDEBUG:",
|
||||
parent=parent,
|
||||
)
|
||||
|
||||
def _on_primary_clicked(self):
|
||||
self._logging_enabled = not self._logging_enabled
|
||||
self.accept()
|
||||
|
||||
@property
|
||||
def logging_enabled(self) -> bool:
|
||||
return self._logging_enabled
|
||||
+129
-129
@@ -1,22 +1,34 @@
|
||||
"""
|
||||
Диалоги для дополнительных настроек префикса и приложений.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QGridLayout, QPushButton,
|
||||
QLabel, QCheckBox, QComboBox, QMessageBox, QSizePolicy
|
||||
import os
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.qt_compat import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
Qt,
|
||||
QVBoxLayout,
|
||||
)
|
||||
from PyQt5.QtCore import Qt
|
||||
from winehelperqt.utils.export_utils import ExportUtils
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AdditionalSettingsDialog(QDialog):
|
||||
"""Диалог для дополнительных настроек префикса (ESync, FSync, DXVK, VKD3D)."""
|
||||
"""Диалог для дополнительных настроек префикса (ESync, FSync, DXVK, VKD3D, Windows)."""
|
||||
|
||||
def __init__(self, prefix_name, parent=None):
|
||||
super().__init__(parent)
|
||||
self.parent_gui = parent
|
||||
self.parent_tab = parent
|
||||
self.prefix_name = prefix_name
|
||||
|
||||
self.setWindowTitle("Дополнительные настройки")
|
||||
@@ -32,60 +44,56 @@ class AdditionalSettingsDialog(QDialog):
|
||||
self.esync_button.setCheckable(True)
|
||||
self.esync_button.setMinimumHeight(32)
|
||||
self.esync_button.setToolTip("Включить/выключить Eventfd-based synchronization.")
|
||||
self.esync_button.clicked.connect(
|
||||
lambda checked: self._toggle_sync_option("WINEESYNC", checked, self.esync_button)
|
||||
)
|
||||
self.esync_button.clicked.connect(lambda checked: self._toggle_sync_option("WINEESYNC", checked))
|
||||
self.esync_button.toggled.connect(
|
||||
lambda checked, btn=self.esync_button: self._update_button_text(btn, "ESync", checked)
|
||||
)
|
||||
self.esync_button.setStyleSheet("QPushButton:checked { background-color: #4CAF50; color: white; font-weight: bold; }")
|
||||
self.esync_button.setStyleSheet(
|
||||
"QPushButton:checked { background-color: #4CAF50; color: white; font-weight: bold; }"
|
||||
)
|
||||
grid_layout.addWidget(self.esync_button, 0, 0)
|
||||
|
||||
self.fsync_button = QPushButton("FSync")
|
||||
self.fsync_button.setMinimumHeight(32)
|
||||
self.fsync_button.setCheckable(True)
|
||||
self.fsync_button.setToolTip("Включить/выключить Futex-based synchronization.")
|
||||
self.fsync_button.clicked.connect(
|
||||
lambda checked: self._toggle_sync_option("WINEFSYNC", checked, self.fsync_button)
|
||||
)
|
||||
self.fsync_button.clicked.connect(lambda checked: self._toggle_sync_option("WINEFSYNC", checked))
|
||||
self.fsync_button.toggled.connect(
|
||||
lambda checked, btn=self.fsync_button: self._update_button_text(btn, "FSync", checked)
|
||||
)
|
||||
self.fsync_button.setStyleSheet("QPushButton:checked { background-color: #4CAF50; color: white; font-weight: bold; }")
|
||||
self.fsync_button.setStyleSheet(
|
||||
"QPushButton:checked { background-color: #4CAF50; color: white; font-weight: bold; }"
|
||||
)
|
||||
grid_layout.addWidget(self.fsync_button, 0, 1)
|
||||
|
||||
self.esync_button.setFocusPolicy(Qt.NoFocus)
|
||||
self.fsync_button.setFocusPolicy(Qt.NoFocus)
|
||||
self.esync_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.fsync_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
|
||||
# DXVK/VKD3D
|
||||
self.dxvk_manage_button = QPushButton("Управление DXVK")
|
||||
self.dxvk_manage_button.setMinimumHeight(32)
|
||||
self.dxvk_manage_button.clicked.connect(
|
||||
lambda: self._open_component_manager('dxvk')
|
||||
)
|
||||
self.dxvk_manage_button.clicked.connect(lambda: self._open_component_manager("dxvk"))
|
||||
self.dxvk_manage_button.setToolTip("Установка или удаление определенной версии DXVK в префиксе.")
|
||||
grid_layout.addWidget(self.dxvk_manage_button, 1, 0)
|
||||
|
||||
self.vkd3d_manage_button = QPushButton("Управление VKD3D")
|
||||
self.vkd3d_manage_button.setMinimumHeight(32)
|
||||
self.vkd3d_manage_button.clicked.connect(
|
||||
lambda: self._open_component_manager('vkd3d-proton')
|
||||
)
|
||||
self.vkd3d_manage_button.clicked.connect(lambda: self._open_component_manager("vkd3d-proton"))
|
||||
self.vkd3d_manage_button.setToolTip("Установка или удаление определенной версии vkd3d-proton в префиксе.")
|
||||
grid_layout.addWidget(self.vkd3d_manage_button, 1, 1)
|
||||
|
||||
self.dxvk_manage_button.setFocusPolicy(Qt.NoFocus)
|
||||
self.vkd3d_manage_button.setFocusPolicy(Qt.NoFocus)
|
||||
self.dxvk_manage_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.vkd3d_manage_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
|
||||
# Ассоциации файлов
|
||||
self.file_associations_button = QPushButton("Ассоциации файлов")
|
||||
self.file_associations_button.setMinimumHeight(32)
|
||||
self.file_associations_button.clicked.connect(self.parent_gui.open_file_associations_manager)
|
||||
self.file_associations_button.clicked.connect(self.parent_tab.open_file_associations_manager)
|
||||
self.file_associations_button.setToolTip(
|
||||
"Настройка открытия определенных типов файлов с помощью нативных приложений Linux."
|
||||
)
|
||||
grid_layout.addWidget(self.file_associations_button, 2, 0)
|
||||
self.file_associations_button.setFocusPolicy(Qt.NoFocus)
|
||||
self.file_associations_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
|
||||
# CPCSP_PROXY
|
||||
self.cpcsp_proxy_button = QPushButton("CPCSP_PROXY")
|
||||
@@ -98,43 +106,65 @@ class AdditionalSettingsDialog(QDialog):
|
||||
self.cpcsp_proxy_button.toggled.connect(
|
||||
lambda checked, btn=self.cpcsp_proxy_button: self._update_button_text(btn, "CPCSP_PROXY", checked)
|
||||
)
|
||||
self.cpcsp_proxy_button.setStyleSheet("QPushButton:checked { background-color: #4CAF50; color: white; font-weight: bold; }")
|
||||
self.cpcsp_proxy_button.setStyleSheet(
|
||||
"QPushButton:checked { background-color: #4CAF50; color: white; font-weight: bold; }"
|
||||
)
|
||||
grid_layout.addWidget(self.cpcsp_proxy_button, 2, 1)
|
||||
self.cpcsp_proxy_button.setFocusPolicy(Qt.NoFocus)
|
||||
self.cpcsp_proxy_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
|
||||
# Версия Windows
|
||||
self.windows_version_button = QPushButton("Версия Windows")
|
||||
self.windows_version_button.setMinimumHeight(32)
|
||||
self.windows_version_button.clicked.connect(self.parent_tab.open_windows_version_manager)
|
||||
self.windows_version_button.setToolTip("Изменить версию Windows, которую Wine сообщает приложениям в префиксе.")
|
||||
grid_layout.addWidget(self.windows_version_button, 3, 0)
|
||||
self.windows_version_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
|
||||
# Ключ защиты Guardant (grdwine)
|
||||
self.grdwine_button = QPushButton("Ключ Guardant")
|
||||
self.grdwine_button.setMinimumHeight(32)
|
||||
self.grdwine_button.setCheckable(True)
|
||||
self.grdwine_button.setToolTip(
|
||||
"Подкидывать в Wine библиотеки grdwine из системного пакета wine-grdwine (ключ защиты Guardant)."
|
||||
)
|
||||
self.grdwine_button.clicked.connect(lambda checked: self._toggle_sync_option("WH_USE_GRDWINE", checked))
|
||||
self.grdwine_button.toggled.connect(
|
||||
lambda checked, btn=self.grdwine_button: self._update_button_text(btn, "Ключ Guardant", checked)
|
||||
)
|
||||
self.grdwine_button.setStyleSheet(
|
||||
"QPushButton:checked { background-color: #4CAF50; color: white; font-weight: bold; }"
|
||||
)
|
||||
grid_layout.addWidget(self.grdwine_button, 3, 1)
|
||||
self.grdwine_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
|
||||
layout.addLayout(grid_layout)
|
||||
|
||||
self.close_button = QPushButton("Закрыть")
|
||||
self.close_button.setMinimumHeight(32)
|
||||
self.close_button.clicked.connect(self.accept)
|
||||
self.close_button.setFocusPolicy(Qt.NoFocus)
|
||||
layout.addWidget(self.close_button, alignment=Qt.AlignRight)
|
||||
self.close_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
layout.addWidget(self.close_button, alignment=Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
self.update_button_states()
|
||||
|
||||
def _toggle_sync_option(self, option_name, is_checked, button):
|
||||
def _toggle_sync_option(self, option_name, is_checked):
|
||||
"""Обновляет опцию синхронизации."""
|
||||
self.parent_gui.update_sync_option(option_name, is_checked)
|
||||
self.parent_tab.update_sync_option(option_name, is_checked)
|
||||
|
||||
def _toggle_cpcsp_proxy(self, is_checked, button):
|
||||
"""Обновляет опцию CPCSP_PROXY и запускает установку при включении."""
|
||||
"""Включает/выключает CPCSP_PROXY через winehelper cpcsp-proxy enable/disable."""
|
||||
if is_checked:
|
||||
# Сначала сохраняем WH_USE_CPCSP_PROXY="1" в last.conf,
|
||||
# чтобы winehelper init-prefix прочитал его при запуске
|
||||
self.parent_gui.update_sync_option("WH_USE_CPCSP_PROXY", True)
|
||||
|
||||
def on_install_finished(success):
|
||||
if not success:
|
||||
# При ошибке откатываем значение в last.conf и сбрасываем кнопку
|
||||
self.parent_gui.update_sync_option("WH_USE_CPCSP_PROXY", False)
|
||||
button.blockSignals(True)
|
||||
button.setChecked(False)
|
||||
self._update_button_text(button, "CPCSP_PROXY", False)
|
||||
button.blockSignals(False)
|
||||
|
||||
self.parent_gui.run_cpcsp_proxy_install(self.prefix_name, on_install_finished)
|
||||
self.parent_tab.run_cpcsp_proxy_install(self.prefix_name, on_install_finished)
|
||||
else:
|
||||
self.parent_gui.update_sync_option("WH_USE_CPCSP_PROXY", False)
|
||||
self.parent_tab.run_cpcsp_proxy_disable(self.prefix_name)
|
||||
|
||||
def _update_button_text(self, button, base_text, is_checked):
|
||||
"""Обновляет текст кнопки, добавляя или убирая галочку."""
|
||||
@@ -145,35 +175,36 @@ class AdditionalSettingsDialog(QDialog):
|
||||
|
||||
def _open_component_manager(self, component_name):
|
||||
"""Открывает менеджер компонентов."""
|
||||
self.parent_gui.open_component_version_manager(component_name)
|
||||
self.parent_tab.open_component_version_manager(component_name)
|
||||
|
||||
def update_button_states(self):
|
||||
"""Обновляет состояние кнопок на основе данных из last.conf."""
|
||||
esync_enabled = self.parent_gui._get_prefix_component_version(
|
||||
self.prefix_name, "WINEESYNC"
|
||||
) == "1"
|
||||
fsync_enabled = self.parent_gui._get_prefix_component_version(
|
||||
self.prefix_name, "WINEFSYNC"
|
||||
) == "1"
|
||||
cpcsp_proxy_enabled = self.parent_gui._get_prefix_component_version(
|
||||
self.prefix_name, "WH_USE_CPCSP_PROXY"
|
||||
) == "1"
|
||||
esync_enabled = self.parent_tab.get_prefix_component_version(self.prefix_name, "WINEESYNC") == "1"
|
||||
fsync_enabled = self.parent_tab.get_prefix_component_version(self.prefix_name, "WINEFSYNC") == "1"
|
||||
cpcsp_proxy_enabled = (
|
||||
self.parent_tab.get_prefix_component_version(self.prefix_name, "WH_USE_CPCSP_PROXY") == "1"
|
||||
)
|
||||
grdwine_enabled = self.parent_tab.get_prefix_component_version(self.prefix_name, "WH_USE_GRDWINE") == "1"
|
||||
|
||||
self.esync_button.blockSignals(True)
|
||||
self.fsync_button.blockSignals(True)
|
||||
self.cpcsp_proxy_button.blockSignals(True)
|
||||
self.grdwine_button.blockSignals(True)
|
||||
|
||||
self.esync_button.setChecked(esync_enabled)
|
||||
self.fsync_button.setChecked(fsync_enabled)
|
||||
self.cpcsp_proxy_button.setChecked(cpcsp_proxy_enabled)
|
||||
self.grdwine_button.setChecked(grdwine_enabled)
|
||||
|
||||
self._update_button_text(self.esync_button, "ESync", esync_enabled)
|
||||
self._update_button_text(self.fsync_button, "FSync", fsync_enabled)
|
||||
self._update_button_text(self.cpcsp_proxy_button, "CPCSP_PROXY", cpcsp_proxy_enabled)
|
||||
self._update_button_text(self.grdwine_button, "Ключ Guardant", grdwine_enabled)
|
||||
|
||||
self.esync_button.blockSignals(False)
|
||||
self.fsync_button.blockSignals(False)
|
||||
self.cpcsp_proxy_button.blockSignals(False)
|
||||
self.grdwine_button.blockSignals(False)
|
||||
|
||||
|
||||
class AppAdditionalSettingsDialog(QDialog):
|
||||
@@ -181,16 +212,16 @@ class AppAdditionalSettingsDialog(QDialog):
|
||||
|
||||
def __init__(self, exe_path, parent=None):
|
||||
super().__init__(parent)
|
||||
self.parent_gui = parent
|
||||
self.exe_path = exe_path
|
||||
self.whdb_path = f"{exe_path}.whdb"
|
||||
self.parent_tab = parent
|
||||
self.exe_path = os.path.abspath(exe_path)
|
||||
self.whdb_path = self.exe_path + ".whdb"
|
||||
|
||||
self.settings = {
|
||||
'WH_MAIN_DECORATED': 'default',
|
||||
'WH_VIRTUAL_DESKTOP': 'default',
|
||||
'WH_FONT_SMOOTHING': '0',
|
||||
'WH_FONT_MSS_REPLACE': '0',
|
||||
'WINE_D3D_CONFIG': ''
|
||||
"WH_MAIN_DECORATED": "default",
|
||||
"WH_VIRTUAL_DESKTOP": "default",
|
||||
"WH_FONT_SMOOTHING": "0",
|
||||
"WH_FONT_MSS_REPLACE": "0",
|
||||
"WINE_D3D_CONFIG": "",
|
||||
}
|
||||
|
||||
self.setWindowTitle("Дополнительные настройки приложения")
|
||||
@@ -235,9 +266,7 @@ class AppAdditionalSettingsDialog(QDialog):
|
||||
|
||||
self.wine_d3d_config_combo = QComboBox()
|
||||
self.wine_d3d_config_combo.setMinimumWidth(250)
|
||||
self.wine_d3d_config_combo.setSizePolicy(
|
||||
QSizePolicy.Preferred, QSizePolicy.Preferred
|
||||
)
|
||||
self.wine_d3d_config_combo.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
|
||||
self.wine_d3d_config_combo.addItem("Не задано", "")
|
||||
self.wine_d3d_config_combo.addItem("renderer=vulkan", "renderer=vulkan")
|
||||
self.wine_d3d_config_combo.addItem("renderer=gdi", "renderer=gdi")
|
||||
@@ -255,13 +284,13 @@ class AppAdditionalSettingsDialog(QDialog):
|
||||
self.save_button = QPushButton("Сохранить")
|
||||
self.save_button.setMinimumHeight(32)
|
||||
self.save_button.clicked.connect(self._save_settings)
|
||||
self.save_button.setFocusPolicy(Qt.NoFocus)
|
||||
self.save_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
button_layout.addWidget(self.save_button)
|
||||
|
||||
self.close_button = QPushButton("Закрыть")
|
||||
self.close_button.setMinimumHeight(32)
|
||||
self.close_button.clicked.connect(self.accept)
|
||||
self.close_button.setFocusPolicy(Qt.NoFocus)
|
||||
self.close_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
button_layout.addWidget(self.close_button)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
@@ -275,48 +304,32 @@ class AppAdditionalSettingsDialog(QDialog):
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.whdb_path, 'r', encoding='utf-8') as f:
|
||||
with open(self.whdb_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
for line in content.split('\n'):
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
|
||||
match = re.match(r'export\s+(\w+)="([^"]*)"', line)
|
||||
if match:
|
||||
var_name = match.group(1)
|
||||
var_value = match.group(2)
|
||||
|
||||
if var_name in self.settings:
|
||||
self.settings[var_name] = var_value
|
||||
for line in content.split("\n"):
|
||||
var_name, var_value = self._parse_export_line(line)
|
||||
if var_name and var_name in self.settings:
|
||||
self.settings[var_name] = var_value
|
||||
|
||||
self._update_ui_from_settings()
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self, "Ошибка",
|
||||
f"Не удалось прочитать файл настроек:\n{self.whdb_path}\n\nОшибка: {e}"
|
||||
)
|
||||
logger.warning("Не удалось прочитать файл настроек %s: %s", self.whdb_path, e)
|
||||
QMessageBox.warning(self, "Ошибка", f"Не удалось прочитать файл настроек:\n{self.whdb_path}\n\nОшибка: {e}")
|
||||
self._update_ui_from_settings()
|
||||
|
||||
def _update_ui_from_settings(self):
|
||||
"""Обновляет элементы управления на основе загруженных настроек."""
|
||||
self.wh_main_decorated_checkbox.setChecked(
|
||||
self.settings.get('WH_MAIN_DECORATED') == '1'
|
||||
)
|
||||
self.wh_main_decorated_checkbox.setChecked(self.settings.get("WH_MAIN_DECORATED") == "1")
|
||||
wh_virtual_desktop = self.settings.get("WH_VIRTUAL_DESKTOP", "")
|
||||
self.wh_virtual_desktop_checkbox.setChecked(
|
||||
self.settings.get('WH_VIRTUAL_DESKTOP') == '1' or
|
||||
(self.settings.get('WH_VIRTUAL_DESKTOP', '').lower() not in ('0', 'default', ''))
|
||||
)
|
||||
self.wh_font_smoothing_checkbox.setChecked(
|
||||
self.settings.get('WH_FONT_SMOOTHING') == '1'
|
||||
)
|
||||
self.wh_font_mss_replace_checkbox.setChecked(
|
||||
self.settings.get('WH_FONT_MSS_REPLACE') == '1'
|
||||
wh_virtual_desktop == "1" or wh_virtual_desktop.lower() not in ("0", "default", "")
|
||||
)
|
||||
self.wh_font_smoothing_checkbox.setChecked(self.settings.get("WH_FONT_SMOOTHING") == "1")
|
||||
self.wh_font_mss_replace_checkbox.setChecked(self.settings.get("WH_FONT_MSS_REPLACE") == "1")
|
||||
|
||||
d3d_value = self.settings.get('WINE_D3D_CONFIG', '')
|
||||
d3d_value = self.settings.get("WINE_D3D_CONFIG", "")
|
||||
index = self.wine_d3d_config_combo.findData(d3d_value)
|
||||
if index >= 0:
|
||||
self.wine_d3d_config_combo.setCurrentIndex(index)
|
||||
@@ -328,49 +341,36 @@ class AppAdditionalSettingsDialog(QDialog):
|
||||
try:
|
||||
existing_lines = []
|
||||
if os.path.exists(self.whdb_path):
|
||||
with open(self.whdb_path, 'r', encoding='utf-8') as f:
|
||||
with open(self.whdb_path, "r", encoding="utf-8") as f:
|
||||
existing_lines = f.readlines()
|
||||
|
||||
updated_vars = set()
|
||||
new_lines = []
|
||||
settings_values = {var_name: self._get_setting_value(var_name) for var_name in self.settings}
|
||||
new_lines = ExportUtils.update_lines(existing_lines, settings_values)
|
||||
|
||||
for line in existing_lines:
|
||||
stripped = line.strip()
|
||||
match = re.match(r'export\s+(\w+)="([^"]*)"', stripped)
|
||||
if match:
|
||||
var_name = match.group(1)
|
||||
if var_name in self.settings:
|
||||
new_lines.append(self._create_export_line(var_name))
|
||||
updated_vars.add(var_name)
|
||||
continue
|
||||
new_lines.append(line)
|
||||
|
||||
for var_name in self.settings:
|
||||
if var_name not in updated_vars:
|
||||
new_lines.append(self._create_export_line(var_name))
|
||||
updated_vars.add(var_name)
|
||||
|
||||
with open(self.whdb_path, 'w', encoding='utf-8') as f:
|
||||
with open(self.whdb_path, "w", encoding="utf-8") as f:
|
||||
f.writelines(new_lines)
|
||||
|
||||
self.accept()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Не удалось сохранить файл настроек %s: %s", self.whdb_path, e)
|
||||
QMessageBox.critical(self, "Ошибка", f"Не удалось сохранить настройки:\n{e}")
|
||||
|
||||
def _create_export_line(self, var_name):
|
||||
"""Создает строку экспорта переменной."""
|
||||
if var_name == 'WINE_D3D_CONFIG':
|
||||
value = self.wine_d3d_config_combo.currentData()
|
||||
elif var_name == 'WH_MAIN_DECORATED':
|
||||
value = '1' if self.wh_main_decorated_checkbox.isChecked() else 'default'
|
||||
elif var_name == 'WH_VIRTUAL_DESKTOP':
|
||||
value = '1' if self.wh_virtual_desktop_checkbox.isChecked() else 'default'
|
||||
elif var_name == 'WH_FONT_SMOOTHING':
|
||||
value = '1' if self.wh_font_smoothing_checkbox.isChecked() else '0'
|
||||
elif var_name == 'WH_FONT_MSS_REPLACE':
|
||||
value = '1' if self.wh_font_mss_replace_checkbox.isChecked() else '0'
|
||||
else:
|
||||
value = ''
|
||||
def _get_setting_value(self, var_name):
|
||||
"""Возвращает текущее значение переменной из UI."""
|
||||
if var_name == "WINE_D3D_CONFIG":
|
||||
return self.wine_d3d_config_combo.currentData()
|
||||
if var_name == "WH_MAIN_DECORATED":
|
||||
return "1" if self.wh_main_decorated_checkbox.isChecked() else "default"
|
||||
if var_name == "WH_VIRTUAL_DESKTOP":
|
||||
return "1" if self.wh_virtual_desktop_checkbox.isChecked() else "default"
|
||||
if var_name == "WH_FONT_SMOOTHING":
|
||||
return "1" if self.wh_font_smoothing_checkbox.isChecked() else "0"
|
||||
if var_name == "WH_FONT_MSS_REPLACE":
|
||||
return "1" if self.wh_font_mss_replace_checkbox.isChecked() else "0"
|
||||
return ""
|
||||
|
||||
return f'export {var_name}="{value}"\n'
|
||||
@staticmethod
|
||||
def _parse_export_line(line):
|
||||
"""Парсит строку export VAR=value, игнорируя комментарии и секции."""
|
||||
return ExportUtils.parse_line(line)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Диалог для выбора версии Windows в префиксе Wine.
|
||||
"""
|
||||
|
||||
from winehelperqt.qt_compat import QComboBox, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||
|
||||
|
||||
class WindowsVersionSelectionDialog(QDialog):
|
||||
"""Диалог выбора версии Windows, сообщаемой приложениям в префиксе."""
|
||||
|
||||
WINDOWS_VERSIONS = [
|
||||
("Windows 2000", "2k"),
|
||||
("Windows XP", "xp"),
|
||||
("Windows XP 64-bit", "xp64"),
|
||||
("Windows Server 2003", "2k3"),
|
||||
("Windows Vista", "vista"),
|
||||
("Windows 7", "7"),
|
||||
("Windows 8", "8"),
|
||||
("Windows 8.1", "81"),
|
||||
("Windows 10", "10"),
|
||||
("Windows 11", "11"),
|
||||
]
|
||||
|
||||
def __init__(self, current_version="10", parent=None):
|
||||
"""
|
||||
Инициализирует диалог выбора версии Windows.
|
||||
|
||||
Args:
|
||||
current_version: Текущая версия Windows из last.conf (по умолчанию "10")
|
||||
parent: Родительский виджет
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Выбор версии Windows")
|
||||
self.setMinimumWidth(380)
|
||||
self.setModal(True)
|
||||
|
||||
self.selected_version = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(10)
|
||||
|
||||
info_label = QLabel("Выберите версию Windows, которую Wine будет сообщать приложениям в префиксе.")
|
||||
info_label.setWordWrap(True)
|
||||
layout.addWidget(info_label)
|
||||
|
||||
self.version_combo = QComboBox()
|
||||
self.version_combo.setMinimumHeight(32)
|
||||
for display_name, value in self.WINDOWS_VERSIONS:
|
||||
self.version_combo.addItem(display_name, value)
|
||||
|
||||
index = self.version_combo.findData(current_version)
|
||||
if index >= 0:
|
||||
self.version_combo.setCurrentIndex(index)
|
||||
|
||||
layout.addWidget(self.version_combo)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.addStretch()
|
||||
|
||||
self.ok_button = QPushButton("Применить")
|
||||
self.ok_button.setMinimumHeight(32)
|
||||
self.ok_button.clicked.connect(self._accept)
|
||||
self.ok_button.setDefault(True)
|
||||
button_layout.addWidget(self.ok_button)
|
||||
|
||||
self.cancel_button = QPushButton("Отмена")
|
||||
self.cancel_button.setMinimumHeight(32)
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(self.cancel_button)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def _accept(self):
|
||||
"""Сохраняет выбранную версию и закрывает диалог."""
|
||||
self.selected_version = self.version_combo.currentData()
|
||||
self.accept()
|
||||
@@ -1,16 +1,23 @@
|
||||
"""
|
||||
Диалог для выбора версии Wine с группировкой.
|
||||
"""
|
||||
|
||||
import re
|
||||
from functools import partial
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
QGridLayout, QScrollArea, QTabWidget, QPushButton, QLabel, QWidget, QVBoxLayout,
|
||||
QHBoxLayout
|
||||
)
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
from winehelperqt.dialogs.base_version_dialog import WineVersionSelectionDialogBase
|
||||
from winehelperqt.qt_compat import (
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
Qt,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from winehelperqt.utils.grid_utils import GridUtils
|
||||
from winehelperqt.utils.version_utils import sort_versions
|
||||
|
||||
|
||||
@@ -25,7 +32,7 @@ class VersionButtonWidget(QPushButton):
|
||||
self.show_icon = show_icon
|
||||
|
||||
self.setMinimumHeight(40)
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
# Создаем внутренний layout для размещения текста и галочки
|
||||
self._setup_layout()
|
||||
@@ -45,7 +52,7 @@ class VersionButtonWidget(QPushButton):
|
||||
# Метка с названием версии (по центру)
|
||||
self.name_label = QLabel(self.display_name)
|
||||
self.name_label.setStyleSheet("background: transparent;")
|
||||
self.name_label.setAlignment(Qt.AlignCenter)
|
||||
self.name_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(self.name_label)
|
||||
layout.addStretch(1)
|
||||
else:
|
||||
@@ -60,7 +67,9 @@ class VersionButtonWidget(QPushButton):
|
||||
# Метка со значком статуса (справа)
|
||||
if self.is_installed:
|
||||
self.check_label = QLabel("✓")
|
||||
self.check_label.setStyleSheet("background: transparent; color: #7fff7f; font-weight: bold; font-size: 22px;")
|
||||
self.check_label.setStyleSheet(
|
||||
"background: transparent; color: #7fff7f; font-weight: bold; font-size: 22px;"
|
||||
)
|
||||
layout.addWidget(self.check_label)
|
||||
else:
|
||||
self.check_label = QLabel("⭳")
|
||||
@@ -70,7 +79,7 @@ class VersionButtonWidget(QPushButton):
|
||||
# Устанавливаем layout для контейнера
|
||||
container.setLayout(layout)
|
||||
|
||||
# Устанавливаем layout кнопки (PyQt5 поддерживает это)
|
||||
# Устанавливаем layout кнопки (Qt поддерживает это)
|
||||
button_layout = QHBoxLayout(self)
|
||||
button_layout.setContentsMargins(0, 0, 0, 0)
|
||||
button_layout.setSpacing(0)
|
||||
@@ -90,7 +99,7 @@ class VersionButtonWidget(QPushButton):
|
||||
def setText(self, text):
|
||||
"""Устанавливает текст метки названия версии."""
|
||||
self.display_name = text
|
||||
if hasattr(self, 'name_label'):
|
||||
if hasattr(self, "name_label"):
|
||||
self.name_label.setText(text)
|
||||
|
||||
|
||||
@@ -113,6 +122,9 @@ class WineVersionSelectionDialog(WineVersionSelectionDialogBase):
|
||||
|
||||
super().__init__(architecture=architecture, parent=parent)
|
||||
|
||||
# UI строим в конце: к этому моменту установлены поля всех уровней
|
||||
self._setup_ui()
|
||||
|
||||
def _create_version_container(self):
|
||||
"""Создает контейнер с вкладками для версий Wine."""
|
||||
self.main_tabs = QTabWidget()
|
||||
@@ -130,17 +142,19 @@ class WineVersionSelectionDialog(WineVersionSelectionDialogBase):
|
||||
installed_layout = QVBoxLayout(installed_tab)
|
||||
installed_scroll_area = QScrollArea()
|
||||
installed_scroll_area.setWidgetResizable(True)
|
||||
installed_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
installed_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
installed_layout.addWidget(installed_scroll_area)
|
||||
installed_content = QWidget()
|
||||
installed_scroll_area.setWidget(installed_content)
|
||||
self.installed_grid_layout = QGridLayout(installed_content)
|
||||
self.installed_grid_layout.setAlignment(Qt.AlignTop)
|
||||
self.installed_grid_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
installed_versions_for_grid = []
|
||||
|
||||
# Системная версия
|
||||
self.system_wine_display_name = self._get_system_wine_version()
|
||||
# Системная версия (может отсутствовать, если Wine не установлен в системе)
|
||||
system_wine_version = self._get_system_wine_version()
|
||||
self.system_wine_available = system_wine_version is not None
|
||||
self.system_wine_display_name = system_wine_version or "Системная версия"
|
||||
installed_versions_for_grid.append((self.system_wine_display_name, "system"))
|
||||
|
||||
# Локально установленные версии
|
||||
@@ -155,14 +169,16 @@ class WineVersionSelectionDialog(WineVersionSelectionDialogBase):
|
||||
value_name = version_data
|
||||
self._installed_version_names.add(value_name)
|
||||
|
||||
self._create_grid_buttons_for_wine(self.installed_grid_layout, installed_versions_for_grid, is_download_tab=False)
|
||||
self._create_grid_buttons_for_wine(
|
||||
self.installed_grid_layout, installed_versions_for_grid, is_download_tab=False
|
||||
)
|
||||
|
||||
# Вкладки с версиями для скачивания
|
||||
if not self.wine_versions_data:
|
||||
error_widget = QWidget()
|
||||
error_layout = QVBoxLayout(error_widget)
|
||||
error_label = QLabel("Список версий пуст или не удалось его загрузить.")
|
||||
error_label.setAlignment(Qt.AlignCenter)
|
||||
error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
error_layout.addWidget(error_label)
|
||||
self.main_tabs.addTab(error_widget, "Ошибка")
|
||||
self.filter_versions()
|
||||
@@ -174,23 +190,14 @@ class WineVersionSelectionDialog(WineVersionSelectionDialogBase):
|
||||
if is_win64:
|
||||
allowed_groups_for_arch = {"WINE AMD64", "WINE WOW64"}
|
||||
tab_order = ["WINE WOW64", "WINE AMD64"]
|
||||
display_names = {
|
||||
"WINE WOW64": "Загрузить wine-wow64",
|
||||
"WINE AMD64": "Загрузить wine-amd64"
|
||||
}
|
||||
display_names = {"WINE WOW64": "Загрузить wine-wow64", "WINE AMD64": "Загрузить wine-amd64"}
|
||||
else:
|
||||
allowed_groups_for_arch = {"WINE I586", "WINE AMD64"}
|
||||
tab_order = ["WINE I586", "WINE AMD64"]
|
||||
display_names = {
|
||||
"WINE I586": "Загрузить wine-i586",
|
||||
"WINE AMD64": "Загрузить wine-amd64"
|
||||
}
|
||||
display_names = {"WINE I586": "Загрузить wine-i586", "WINE AMD64": "Загрузить wine-amd64"}
|
||||
|
||||
available_keys = [
|
||||
key for key in self.wine_versions_data.keys()
|
||||
if key in allowed_groups_for_arch
|
||||
]
|
||||
group_keys = sorted(available_keys, key=lambda k: tab_order.index(k))
|
||||
available_keys = [key for key in self.wine_versions_data if key in allowed_groups_for_arch]
|
||||
group_keys = sorted(available_keys, key=tab_order.index)
|
||||
|
||||
for key in group_keys:
|
||||
versions = self.wine_versions_data.get(key, [])
|
||||
@@ -209,14 +216,14 @@ class WineVersionSelectionDialog(WineVersionSelectionDialogBase):
|
||||
|
||||
scroll_area = QScrollArea()
|
||||
scroll_area.setWidgetResizable(True)
|
||||
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
tab_layout.addWidget(scroll_area)
|
||||
|
||||
scroll_content = QWidget()
|
||||
scroll_area.setWidget(scroll_content)
|
||||
|
||||
grid_layout = QGridLayout(scroll_content)
|
||||
grid_layout.setAlignment(Qt.AlignTop)
|
||||
grid_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
self._create_grid_buttons_for_wine(grid_layout, versions_list, is_download_tab=True)
|
||||
self.main_tabs.addTab(tab_page, title)
|
||||
@@ -225,7 +232,7 @@ class WineVersionSelectionDialog(WineVersionSelectionDialogBase):
|
||||
"""Создает кнопки для версий в сетке."""
|
||||
is_win64_prefix = self.architecture == "win64"
|
||||
is_win32_prefix = self.architecture == "win32"
|
||||
re_32bit_version = re.compile(r'i[3-6]86|i586')
|
||||
re_32bit_version = re.compile(r"i[3-6]86|i586")
|
||||
|
||||
row, col = 0, 0
|
||||
for version_data in versions_list:
|
||||
@@ -253,7 +260,11 @@ class WineVersionSelectionDialog(WineVersionSelectionDialogBase):
|
||||
|
||||
if value_name == "system":
|
||||
btn.setText(f"{display_name} (Системный)")
|
||||
btn.setToolTip("Системная версия Wine, установленная в вашей ОС.")
|
||||
if self.system_wine_available:
|
||||
btn.setToolTip("Системная версия Wine, установленная в вашей ОС.")
|
||||
else:
|
||||
btn.setEnabled(False)
|
||||
btn.setToolTip("Wine не установлен в системе.")
|
||||
|
||||
# Отключение 32-битных версий для 64-битного префикса
|
||||
if not is_download_tab and is_win64_prefix:
|
||||
@@ -262,8 +273,8 @@ class WineVersionSelectionDialog(WineVersionSelectionDialogBase):
|
||||
btn.setToolTip("Эта 32-битная версия Wine несовместима с 64-битным префиксом.")
|
||||
|
||||
# Отключение 64-битных версий для 32-битного префикса
|
||||
if not is_download_tab and is_win32_prefix:
|
||||
if value_name == "system" or 'wow64' in value_name:
|
||||
if not is_download_tab and is_win32_prefix and btn.isEnabled():
|
||||
if value_name == "system" or "wow64" in value_name:
|
||||
btn.setEnabled(False)
|
||||
btn.setToolTip("Эта 64-битная версия Wine несовместима с 32-битным префиксом.")
|
||||
|
||||
@@ -280,43 +291,39 @@ class WineVersionSelectionDialog(WineVersionSelectionDialogBase):
|
||||
|
||||
def filter_versions(self):
|
||||
"""Фильтрует видимость кнопок версий на основе текста поиска."""
|
||||
search_text = self.search_edit.text().lower()
|
||||
search_text = self.search_edit.text()
|
||||
|
||||
# Фильтр для вкладки "Установленные"
|
||||
if hasattr(self, 'installed_grid_layout'):
|
||||
self._filter_grid(self.installed_grid_layout, search_text)
|
||||
if self.installed_grid_layout:
|
||||
GridUtils.filter_buttons(self.installed_buttons, search_text, self.installed_grid_layout, self.NUM_COLUMNS)
|
||||
|
||||
# Фильтр для вкладок с версиями (теперь напрямую в main_tabs)
|
||||
# Пропускаем первую вкладку "Установленные" (индекс 0)
|
||||
for i in range(1, self.main_tabs.count()):
|
||||
tab_widget = self.main_tabs.widget(i)
|
||||
# Ищем QGridLayout внутри вкладки
|
||||
scroll_area = tab_widget.findChild(QScrollArea)
|
||||
if scroll_area:
|
||||
scroll_content = scroll_area.widget()
|
||||
if scroll_content:
|
||||
grid_layout = scroll_content.layout()
|
||||
if grid_layout:
|
||||
any_visible = self._filter_grid(grid_layout, search_text)
|
||||
self.main_tabs.setTabEnabled(i, any_visible)
|
||||
grid_layout, buttons = self._get_grid_layout_and_buttons(tab_widget)
|
||||
if grid_layout:
|
||||
visible = GridUtils.filter_buttons(buttons, search_text, grid_layout, self.NUM_COLUMNS)
|
||||
self.main_tabs.setTabEnabled(i, bool(visible))
|
||||
|
||||
def _filter_grid(self, grid_layout, search_text):
|
||||
"""Helper-функция для фильтрации кнопок в одной сетке."""
|
||||
any_visible_in_grid = False
|
||||
if not grid_layout:
|
||||
return False
|
||||
def _get_grid_layout_and_buttons(self, tab_widget):
|
||||
"""Возвращает QGridLayout и список кнопок из вкладки."""
|
||||
scroll_area = tab_widget.findChild(QScrollArea)
|
||||
if not scroll_area:
|
||||
return None, []
|
||||
scroll_content = scroll_area.widget()
|
||||
if not scroll_content:
|
||||
return None, []
|
||||
grid_layout = scroll_content.layout()
|
||||
if not isinstance(grid_layout, QGridLayout):
|
||||
return None, []
|
||||
|
||||
buttons = []
|
||||
for j in range(grid_layout.count()):
|
||||
item = grid_layout.itemAt(j)
|
||||
if item:
|
||||
btn_widget = item.widget()
|
||||
if isinstance(btn_widget, (QPushButton, VersionButtonWidget)):
|
||||
is_match = search_text in btn_widget.text().lower()
|
||||
btn_widget.setVisible(is_match)
|
||||
if is_match:
|
||||
any_visible_in_grid = True
|
||||
|
||||
return any_visible_in_grid
|
||||
if item and item.widget():
|
||||
buttons.append(item.widget())
|
||||
return grid_layout, buttons
|
||||
|
||||
def on_version_selected(self, version_name, display_name=None):
|
||||
"""Обрабатывает выбор версии Wine."""
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Общий модуль для диалогов с вводом каналов WINEDEBUG и их настроек.
|
||||
|
||||
Единственный источник хранения (QSettings) и констант, которым пользуются
|
||||
CreateLogDialog, PrefixLoggingDialog и вкладка «Менеджер префиксов».
|
||||
"""
|
||||
|
||||
from winehelperqt.qt_compat import (
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QSettings,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
# Хранение настроек логирования WINEDEBUG (один источник)
|
||||
WINEDEBUG_SETTINGS_ORG = "winehelper"
|
||||
WINEDEBUG_SETTINGS_APP = "winedebug_run"
|
||||
|
||||
# Значения по умолчанию и имена ключей.
|
||||
# Ключи каналов раздельные: два режима пишут в разные лог-файлы,
|
||||
# поэтому «Запомнить каналы» в каждом режиме работает независимо
|
||||
DEFAULT_WINEDEBUG = "+loaddll,+seh"
|
||||
CREATE_LOG_CHANNELS_KEY = "winedebug_channels_run"
|
||||
PREFIX_LOG_CHANNELS_KEY = "prefix_tab_winedebug_channels"
|
||||
PREFIX_LOGGING_ENABLED_KEY = "prefix_tab_logging_enabled"
|
||||
|
||||
|
||||
def winedebug_settings() -> QSettings:
|
||||
"""QSettings для каналов WINEDEBUG (общее хранилище диалогов и вкладки префиксов)."""
|
||||
return QSettings(WINEDEBUG_SETTINGS_ORG, WINEDEBUG_SETTINGS_APP)
|
||||
|
||||
|
||||
class WinedebugChannelsDialog(QDialog):
|
||||
"""
|
||||
Базовый диалог с полем ввода каналов WINEDEBUG и чекбоксом «Запомнить каналы».
|
||||
|
||||
Подклассы предоставляют только заголовок, текст описания, текст главной
|
||||
кнопки и ключ каналов (а при необходимости переопределяют _on_primary_clicked).
|
||||
"""
|
||||
|
||||
def __init__(self, title, description, primary_button_text, channels_key, channels_label=None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(title)
|
||||
self.setMinimumSize(520, 240)
|
||||
self.setModal(True)
|
||||
|
||||
self._settings = winedebug_settings()
|
||||
self._channels_key = channels_key
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
desc_label = QLabel(description)
|
||||
desc_label.setWordWrap(True)
|
||||
layout.addWidget(desc_label)
|
||||
|
||||
if channels_label:
|
||||
layout.addSpacing(8)
|
||||
channels_label_widget = QLabel(channels_label)
|
||||
layout.addWidget(channels_label_widget)
|
||||
|
||||
winedebug_layout = QHBoxLayout()
|
||||
winedebug_layout.addWidget(QLabel("<b>WINEDEBUG:</b>"))
|
||||
self.winedebug_edit = QLineEdit()
|
||||
self.winedebug_edit.setPlaceholderText("Например: +loaddll,+seh")
|
||||
winedebug_layout.addWidget(self.winedebug_edit)
|
||||
layout.addLayout(winedebug_layout)
|
||||
|
||||
hint_label = QLabel("Пример: +loaddll,-seh,fixme-all,+tid")
|
||||
hint_label.setStyleSheet("color: gray; font-size: 13px;")
|
||||
layout.addWidget(hint_label)
|
||||
|
||||
self.remember_cb = QCheckBox("Запомнить каналы")
|
||||
layout.addWidget(self.remember_cb)
|
||||
|
||||
self._restore_state()
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
primary_button = QPushButton(primary_button_text)
|
||||
primary_button.clicked.connect(self._on_primary_clicked)
|
||||
cancel_button = QPushButton("Отмена")
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(primary_button)
|
||||
button_layout.addWidget(cancel_button)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def _on_primary_clicked(self):
|
||||
"""Действие главной кнопки (подклассы могут переопределить)."""
|
||||
self.accept()
|
||||
|
||||
def _restore_state(self):
|
||||
saved = self._settings.value(self._channels_key, "")
|
||||
if saved:
|
||||
self.winedebug_edit.setText(saved)
|
||||
self.remember_cb.setChecked(True)
|
||||
else:
|
||||
self.winedebug_edit.setText(DEFAULT_WINEDEBUG)
|
||||
self.remember_cb.setChecked(False)
|
||||
|
||||
def accept(self):
|
||||
if self.remember_cb.isChecked():
|
||||
value = self.winedebug_edit.text().strip()
|
||||
if value:
|
||||
self._settings.setValue(self._channels_key, value)
|
||||
else:
|
||||
self._settings.remove(self._channels_key)
|
||||
super().accept()
|
||||
|
||||
@property
|
||||
def winedebug_value(self) -> str:
|
||||
value = self.winedebug_edit.text().strip()
|
||||
return value or DEFAULT_WINEDEBUG
|
||||
@@ -1,23 +1,41 @@
|
||||
"""
|
||||
Диалог для управления компонентами Winetricks.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from functools import partial
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QTabWidget,
|
||||
QTextEdit, QListWidget, QListWidgetItem, QMessageBox, QLineEdit, QWidget
|
||||
)
|
||||
from PyQt5.QtCore import Qt, QProcess, QTimer, QProcessEnvironment, pyqtSignal
|
||||
from PyQt5.QtGui import QTextCursor, QTextCharFormat, QFont
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.parsers.winetricks_parser import WinetricksOutputParser
|
||||
from winehelperqt.services.winetricks_service import WinetricksLogReader
|
||||
from winehelperqt.qt_compat import (
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMessageBox,
|
||||
QProcess,
|
||||
QPushButton,
|
||||
Qt,
|
||||
QTabWidget,
|
||||
QTextCharFormat,
|
||||
QTextCursor,
|
||||
QTextEdit,
|
||||
QTimer,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
pyqtSignal,
|
||||
)
|
||||
from winehelperqt.services.winetricks_service import WinetricksCacheService, WinetricksLogReader
|
||||
from winehelperqt.utils.dialog_utils import DialogUtils
|
||||
from winehelperqt.utils.environment import EnvironmentConfig
|
||||
from winehelperqt.utils.font_utils import fixed_font
|
||||
from winehelperqt.utils.process_utils import ProcessUtils
|
||||
|
||||
logger = get_logger(__name__)
|
||||
# Отдельный логгер для вывода внешних процессов (bash, winetricks),
|
||||
# чтобы в консоли отличать его от событий самого GUI
|
||||
process_logger = get_logger("process")
|
||||
|
||||
|
||||
class NoAutoScrollListWidget(QListWidget):
|
||||
@@ -27,7 +45,7 @@ class NoAutoScrollListWidget(QListWidget):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._scroll_enabled = False # Флаг для ручной прокрутки
|
||||
|
||||
def scrollTo(self, index, hint=QListWidget.EnsureVisible):
|
||||
def scrollTo(self, index, hint=QListWidget.ScrollHint.EnsureVisible):
|
||||
"""Переопределяем, чтобы игнорировать автоматическую прокрутку."""
|
||||
if self._scroll_enabled:
|
||||
super().scrollTo(index, hint)
|
||||
@@ -57,8 +75,8 @@ class WinetricksManagerDialog(QDialog):
|
||||
super().__init__(parent)
|
||||
self.prefix_path = prefix_path
|
||||
self.winetricks_path = winetricks_path
|
||||
self.wine_executable = wine_executable or 'wine'
|
||||
self.winehelper_path = winehelper_path or 'winehelper'
|
||||
self.wine_executable = wine_executable or "wine"
|
||||
self.winehelper_path = winehelper_path or "winehelper"
|
||||
self.initial_states = {}
|
||||
self.apply_process = None
|
||||
self.installation_finished = False
|
||||
@@ -66,10 +84,10 @@ class WinetricksManagerDialog(QDialog):
|
||||
self.processes = {}
|
||||
self.category_statuses = {}
|
||||
self.previous_tab_widget = None
|
||||
self.cache_dir = os.path.join(os.path.expanduser("~"), ".cache", "winehelper", "winetricks")
|
||||
os.makedirs(self.cache_dir, exist_ok=True)
|
||||
self.cache_service = WinetricksCacheService()
|
||||
self.is_reloading_after_cache_clear = False
|
||||
self.cache_clear_process = None
|
||||
self._install_queue = []
|
||||
|
||||
self.setWindowTitle(f"Менеджер компонентов для префикса: {os.path.basename(prefix_path)}")
|
||||
self.setMinimumSize(800, 500)
|
||||
@@ -79,11 +97,7 @@ class WinetricksManagerDialog(QDialog):
|
||||
self.tabs = QTabWidget()
|
||||
main_layout.addWidget(self.tabs)
|
||||
|
||||
self.categories = {
|
||||
"Библиотеки": "dlls",
|
||||
"Шрифты": "fonts",
|
||||
"Настройки": "settings"
|
||||
}
|
||||
self.categories = {"Библиотеки": "dlls", "Шрифты": "fonts", "Настройки": "settings"}
|
||||
self.list_widgets = {}
|
||||
self.search_edits = {}
|
||||
for display_name, internal_name in self.categories.items():
|
||||
@@ -93,7 +107,7 @@ class WinetricksManagerDialog(QDialog):
|
||||
|
||||
self.log_output = QTextEdit()
|
||||
self.log_output.setReadOnly(True)
|
||||
self.log_output.setFont(QFont('DejaVu Sans Mono', 10))
|
||||
self.log_output.setFont(fixed_font(10))
|
||||
self.log_output.setMaximumHeight(150)
|
||||
self.log_output.setText(self.INFO_TEXT)
|
||||
main_layout.addWidget(self.log_output)
|
||||
@@ -130,9 +144,12 @@ class WinetricksManagerDialog(QDialog):
|
||||
def on_tab_switched(self, index):
|
||||
"""Обрабатывает переключение вкладок."""
|
||||
if self.previous_tab_widget:
|
||||
search_edit = self.previous_tab_widget.findChild(QLineEdit)
|
||||
if search_edit:
|
||||
search_edit.clear()
|
||||
previous_index = self.tabs.indexOf(self.previous_tab_widget)
|
||||
if previous_index >= 0:
|
||||
category = list(self.categories.values())[previous_index]
|
||||
search_edit = self.search_edits.get(category)
|
||||
if search_edit:
|
||||
search_edit.clear()
|
||||
|
||||
if self.installation_finished:
|
||||
self.log_output.setText(self.INFO_TEXT)
|
||||
@@ -150,7 +167,8 @@ class WinetricksManagerDialog(QDialog):
|
||||
layout.addWidget(search_edit)
|
||||
|
||||
list_widget = NoAutoScrollListWidget()
|
||||
list_widget.setAutoScroll(False) # Отключаем автопрокрутку
|
||||
list_widget.setAutoScroll(False)
|
||||
list_widget.setTextElideMode(Qt.TextElideMode.ElideNone)
|
||||
list_widget.itemChanged.connect(self._on_item_changed)
|
||||
list_widget.currentItemChanged.connect(self._update_ui_state)
|
||||
layout.addWidget(list_widget)
|
||||
@@ -176,7 +194,7 @@ class WinetricksManagerDialog(QDialog):
|
||||
for i in range(list_widget.count()):
|
||||
item = list_widget.item(i)
|
||||
if not item.isHidden():
|
||||
list_widget.scrollToItem(item, QListWidget.PositionAtTop)
|
||||
list_widget.scrollToItem(item, QListWidget.ScrollHint.PositionAtTop)
|
||||
break
|
||||
|
||||
list_widget.blockSignals(False)
|
||||
@@ -190,49 +208,27 @@ class WinetricksManagerDialog(QDialog):
|
||||
for internal_name in self.categories.values():
|
||||
self._start_load_process(internal_name)
|
||||
|
||||
def _get_cache_path(self, category):
|
||||
"""Возвращает путь к файлу кэша для указанной категории."""
|
||||
return os.path.join(self.cache_dir, f"{category}.json")
|
||||
|
||||
def _get_winetricks_hash(self):
|
||||
"""Вычисляет хэш файла winetricks для проверки его обновления."""
|
||||
try:
|
||||
hasher = hashlib.sha256()
|
||||
with open(self.winetricks_path, 'rb') as f:
|
||||
while chunk := f.read(4096):
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest()
|
||||
except (IOError, OSError):
|
||||
return None
|
||||
|
||||
def _start_load_process(self, category):
|
||||
"""Запускает QProcess для получения списка компонентов, используя кэш."""
|
||||
cache_path = self._get_cache_path(category)
|
||||
cache_ttl_seconds = 86400
|
||||
|
||||
if os.path.exists(cache_path):
|
||||
try:
|
||||
with open(cache_path, 'r', encoding='utf-8') as f:
|
||||
cache_data = json.load(f)
|
||||
cache_age = time.time() - cache_data.get("timestamp", 0)
|
||||
winetricks_hash = self._get_winetricks_hash()
|
||||
if cache_age < cache_ttl_seconds and cache_data.get("hash") == winetricks_hash:
|
||||
QTimer.singleShot(0, lambda: self._on_load_finished(
|
||||
category, 0, QProcess.NormalExit, from_cache=cache_data.get("output")
|
||||
))
|
||||
return
|
||||
except (json.JSONDecodeError, IOError, KeyError):
|
||||
self._log(f"--- Кэш для '{category}' поврежден, будет выполнена перезагрузка. ---")
|
||||
cached_output = self.cache_service.get_cached_data(category, self.winetricks_path)
|
||||
if cached_output is not None:
|
||||
QTimer.singleShot(
|
||||
0, lambda: self._on_load_finished(category, 0, QProcess.ExitStatus.NormalExit, from_cache=cached_output)
|
||||
)
|
||||
return
|
||||
|
||||
process = QProcess(self)
|
||||
self.processes[category] = process
|
||||
process.setProcessChannelMode(QProcess.MergedChannels)
|
||||
ProcessUtils.prepare_process(process, {"WINEPREFIX": self.prefix_path})
|
||||
|
||||
env = QProcessEnvironment.systemEnvironment()
|
||||
env.insert("WINEPREFIX", self.prefix_path)
|
||||
process.setProcessEnvironment(env)
|
||||
|
||||
process.finished.connect(partial(self._on_load_finished, category))
|
||||
process.finished.connect(
|
||||
lambda exit_code, exit_status: self._on_load_finished(category, exit_code, exit_status, process=process)
|
||||
)
|
||||
logger.debug(
|
||||
"Запуск загрузки списка winetricks: %s %s",
|
||||
shlex.quote(self.winehelper_path),
|
||||
["winetricks", category, "list"],
|
||||
)
|
||||
process.start(self.winehelper_path, ["winetricks", category, "list"])
|
||||
|
||||
def _parse_winetricks_log(self):
|
||||
@@ -247,62 +243,64 @@ class WinetricksManagerDialog(QDialog):
|
||||
|
||||
for item_data in items:
|
||||
item = QListWidgetItem(item_data.item_text)
|
||||
item.setData(Qt.UserRole, item_data.name)
|
||||
item.setFont(QFont("DejaVu Sans Mono", 10))
|
||||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
||||
item.setCheckState(Qt.Checked if item_data.is_installed else Qt.Unchecked)
|
||||
item.setData(Qt.ItemDataRole.UserRole, item_data.name)
|
||||
item.setFont(fixed_font(10))
|
||||
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||
item.setCheckState(Qt.CheckState.Checked if item_data.is_installed else Qt.CheckState.Unchecked)
|
||||
list_widget.addItem(item)
|
||||
self.initial_states[item_data.name] = item_data.is_installed
|
||||
|
||||
return found_items
|
||||
|
||||
def _on_load_finished(self, category, exit_code, exit_status, from_cache=None):
|
||||
def _on_load_finished(self, category, exit_code, exit_status, from_cache=None, process=None):
|
||||
"""Обрабатывает завершение загрузки списка компонентов."""
|
||||
if from_cache is not None:
|
||||
output = from_cache
|
||||
process = None
|
||||
else:
|
||||
process = self.processes[category]
|
||||
output = process.readAllStandardOutput().data().decode('utf-8', 'ignore')
|
||||
if process is None:
|
||||
process = self.processes.get(category)
|
||||
if process is None:
|
||||
return
|
||||
output = process.readAllStandardOutput().data().decode("utf-8", "ignore")
|
||||
# Убираем завершённый процесс из словаря и удаляем его,
|
||||
# чтобы QProcess не копился в диалоге до его закрытия
|
||||
if self.processes.get(category) is process:
|
||||
self.processes.pop(category, None)
|
||||
process.deleteLater()
|
||||
|
||||
list_widget = self.list_widgets[category]
|
||||
category_display_name = next(k for k, v in self.categories.items() if v == category)
|
||||
list_widget = self.list_widgets.get(category)
|
||||
if list_widget is None:
|
||||
return
|
||||
|
||||
category_display_name = next((k for k, v in self.categories.items() if v == category), category)
|
||||
|
||||
list_widget.clear()
|
||||
|
||||
if exit_code != 0 or exit_status != QProcess.NormalExit:
|
||||
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
|
||||
self.category_statuses[category] = "error"
|
||||
error_string = process.errorString() if process else "N/A"
|
||||
self._log(f"--- Ошибка загрузки категории '{category_display_name}' (код: {exit_code}) ---", "red")
|
||||
if exit_status == QProcess.CrashExit:
|
||||
if exit_status == QProcess.ExitStatus.CrashExit:
|
||||
self._log("--- Процесс winetricks завершился аварийно. ---", "red")
|
||||
if error_string != "Неизвестная ошибка":
|
||||
self._log(f"--- Системная ошибка: {error_string} ---", "red")
|
||||
self._log(output if output.strip() else "Winetricks не вернул вывод. Проверьте, что он работает корректно.")
|
||||
self._log("--------------------------------------------------", "red")
|
||||
else:
|
||||
self.category_statuses[category] = "ok"
|
||||
installed_verbs = self._parse_winetricks_log()
|
||||
found_items = self._parse_winetricks_list_output(output, installed_verbs, list_widget, category)
|
||||
|
||||
if from_cache is None:
|
||||
cache_path = self._get_cache_path(category)
|
||||
winetricks_hash = self._get_winetricks_hash()
|
||||
if winetricks_hash:
|
||||
cache_data = {
|
||||
"timestamp": time.time(),
|
||||
"hash": winetricks_hash,
|
||||
"output": output
|
||||
}
|
||||
try:
|
||||
with open(cache_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cache_data, f, ensure_ascii=False, indent=2)
|
||||
except (IOError, OSError) as e:
|
||||
self._log(f"--- Не удалось сохранить кэш для '{category}': {e} ---")
|
||||
if not self.cache_service.save_to_cache(category, output, self.winetricks_path):
|
||||
self._log(f"--- Не удалось сохранить кэш для '{category}' ---")
|
||||
if not found_items and output.strip():
|
||||
self._log(f"--- Не удалось распознать вывод для категории '{category}' ---")
|
||||
self._log(output)
|
||||
self._log("--------------------------------------------------")
|
||||
|
||||
has_errors = any(status == 'error' for status in self.category_statuses.values())
|
||||
has_errors = any(status == "error" for status in self.category_statuses.values())
|
||||
|
||||
self.loading_count -= 1
|
||||
if self.loading_count == 0:
|
||||
@@ -316,13 +314,13 @@ class WinetricksManagerDialog(QDialog):
|
||||
|
||||
def _on_item_changed(self, item):
|
||||
"""Обрабатывает изменение состояния чекбокса."""
|
||||
name = item.data(Qt.UserRole)
|
||||
name = item.data(Qt.ItemDataRole.UserRole)
|
||||
if name in self.initial_states and self.initial_states.get(name) is True:
|
||||
if item.checkState() == Qt.Unchecked:
|
||||
if item.checkState() == Qt.CheckState.Unchecked:
|
||||
list_widget = item.listWidget()
|
||||
if list_widget:
|
||||
list_widget.blockSignals(True)
|
||||
item.setCheckState(Qt.Checked)
|
||||
item.setCheckState(Qt.CheckState.Checked)
|
||||
if list_widget:
|
||||
list_widget.blockSignals(False)
|
||||
self._update_ui_state()
|
||||
@@ -333,10 +331,10 @@ class WinetricksManagerDialog(QDialog):
|
||||
for list_widget in self.list_widgets.values():
|
||||
for i in range(list_widget.count()):
|
||||
item = list_widget.item(i)
|
||||
name = item.data(Qt.UserRole)
|
||||
name = item.data(Qt.ItemDataRole.UserRole)
|
||||
if name in self.initial_states:
|
||||
initial_state = self.initial_states[name]
|
||||
current_state = item.checkState() == Qt.Checked
|
||||
current_state = item.checkState() == Qt.CheckState.Checked
|
||||
if current_state != initial_state:
|
||||
has_changes = True
|
||||
break
|
||||
@@ -347,19 +345,26 @@ class WinetricksManagerDialog(QDialog):
|
||||
|
||||
is_reinstallable = False
|
||||
if not has_changes:
|
||||
current_list_widget = self.tabs.currentWidget().findChild(QListWidget)
|
||||
if current_list_widget:
|
||||
current_item = current_list_widget.currentItem()
|
||||
if current_item:
|
||||
name = current_item.data(Qt.UserRole)
|
||||
if self.initial_states.get(name, False):
|
||||
is_reinstallable = True
|
||||
current_index = self.tabs.currentIndex()
|
||||
if current_index >= 0:
|
||||
category = list(self.categories.values())[current_index]
|
||||
current_list_widget = self.list_widgets.get(category)
|
||||
if current_list_widget:
|
||||
current_item = current_list_widget.currentItem()
|
||||
if current_item:
|
||||
name = current_item.data(Qt.ItemDataRole.UserRole)
|
||||
if self.initial_states.get(name, False):
|
||||
is_reinstallable = True
|
||||
|
||||
self.reinstall_button.setEnabled(is_reinstallable)
|
||||
|
||||
def reinstall_selected(self):
|
||||
"""Переустанавливает выбранный компонент."""
|
||||
current_list_widget = self.tabs.currentWidget().findChild(QListWidget)
|
||||
current_index = self.tabs.currentIndex()
|
||||
if current_index < 0:
|
||||
return
|
||||
category = list(self.categories.values())[current_index]
|
||||
current_list_widget = self.list_widgets.get(category)
|
||||
if not current_list_widget:
|
||||
return
|
||||
|
||||
@@ -367,7 +372,7 @@ class WinetricksManagerDialog(QDialog):
|
||||
if not current_item:
|
||||
return
|
||||
|
||||
name = current_item.data(Qt.UserRole)
|
||||
name = current_item.data(Qt.ItemDataRole.UserRole)
|
||||
if not name:
|
||||
return
|
||||
|
||||
@@ -387,12 +392,12 @@ class WinetricksManagerDialog(QDialog):
|
||||
for list_widget in self.list_widgets.values():
|
||||
for i in range(list_widget.count()):
|
||||
item = list_widget.item(i)
|
||||
name = item.data(Qt.UserRole)
|
||||
name = item.data(Qt.ItemDataRole.UserRole)
|
||||
if name not in self.initial_states:
|
||||
continue
|
||||
|
||||
initial_state = self.initial_states[name]
|
||||
current_state = item.checkState() == Qt.Checked
|
||||
current_state = item.checkState() == Qt.CheckState.Checked
|
||||
|
||||
if current_state != initial_state:
|
||||
if initial_state:
|
||||
@@ -423,16 +428,27 @@ class WinetricksManagerDialog(QDialog):
|
||||
|
||||
# Устанавливает новые компоненты (без --force)
|
||||
if verbs_to_install:
|
||||
self._log(f"Выполнение установки: winehelper winetricks {' '.join(verbs_to_install)}")
|
||||
self._run_winetricks_process(verbs_to_install, use_force=False)
|
||||
self._install_queue.append((verbs_to_install, False))
|
||||
|
||||
# Переустанавливает компоненты (с --force)
|
||||
if verbs_to_reinstall:
|
||||
if verbs_to_install:
|
||||
# Если уже что-то устанавливается, добавляем небольшую задержку
|
||||
self._log("\n---\n")
|
||||
self._log(f"Выполнение переустановки: winehelper winetricks --force {' '.join(verbs_to_reinstall)}")
|
||||
self._run_winetricks_process(verbs_to_reinstall, use_force=True)
|
||||
self._install_queue.append((verbs_to_reinstall, True))
|
||||
|
||||
# Запускаем первую задачу, если сейчас ничего не выполняется
|
||||
if not self.apply_process or self.apply_process.state() != QProcess.ProcessState.Running:
|
||||
self._process_next_install_task()
|
||||
|
||||
def _process_next_install_task(self):
|
||||
"""Берёт следующую задачу из очереди и запускает процесс."""
|
||||
if not self._install_queue:
|
||||
return
|
||||
|
||||
verbs, use_force = self._install_queue.pop(0)
|
||||
if use_force:
|
||||
self._log(f"Выполнение переустановки: winehelper winetricks --force {' '.join(verbs)}")
|
||||
else:
|
||||
self._log(f"Выполнение установки: winehelper winetricks {' '.join(verbs)}")
|
||||
self._run_winetricks_process(verbs, use_force)
|
||||
|
||||
def _run_winetricks_process(self, verbs, use_force=False):
|
||||
"""Запускает процесс winetricks с указанными компонентами.
|
||||
@@ -446,42 +462,50 @@ class WinetricksManagerDialog(QDialog):
|
||||
args.append("--force")
|
||||
args.extend(verbs)
|
||||
|
||||
if not self.apply_process:
|
||||
self.apply_process = QProcess(self)
|
||||
self.apply_process.setProcessChannelMode(QProcess.MergedChannels)
|
||||
# Всегда создаём новый QProcess, чтобы избежать повторного вызова start()
|
||||
# и дублирования обработчиков сигналов.
|
||||
if self.apply_process:
|
||||
try:
|
||||
self.apply_process.readyReadStandardOutput.disconnect()
|
||||
self.apply_process.finished.disconnect(self.on_apply_finished)
|
||||
except TypeError as e:
|
||||
logger.debug("Отсутствуют подключенные обработчики процесса winetricks: %s", e)
|
||||
# Удаляем завершённый процесс, чтобы он не копился
|
||||
# в диалоге по числу выполненных операций
|
||||
self.apply_process.deleteLater()
|
||||
|
||||
env = QProcessEnvironment.systemEnvironment()
|
||||
env.insert("WINEPREFIX", self.prefix_path)
|
||||
self.apply_process.setProcessEnvironment(env)
|
||||
self.apply_process.readyReadStandardOutput.connect(
|
||||
lambda: self.log_output.append(
|
||||
self.apply_process.readAllStandardOutput().data().decode('utf-8', 'ignore').strip()
|
||||
)
|
||||
)
|
||||
self.apply_process.finished.connect(self.on_apply_finished)
|
||||
self.apply_process = QProcess(self)
|
||||
ProcessUtils.prepare_process(self.apply_process, {"WINEPREFIX": self.prefix_path})
|
||||
self.apply_process.readyReadStandardOutput.connect(
|
||||
lambda: self._log(self.apply_process.readAllStandardOutput().data().decode("utf-8", "ignore").strip())
|
||||
)
|
||||
self.apply_process.finished.connect(self.on_apply_finished)
|
||||
|
||||
self.apply_process.start(self.winehelper_path, ["winetricks"] + args)
|
||||
|
||||
def on_apply_finished(self, exit_code, exit_status):
|
||||
"""Обрабатывает завершение применения изменений."""
|
||||
if self.user_cancelled:
|
||||
self._install_queue.clear()
|
||||
self._log("\n=== Установка прервана пользователем. ===")
|
||||
self.close()
|
||||
return
|
||||
|
||||
if exit_code != 0 or exit_status != QProcess.NormalExit:
|
||||
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
|
||||
self._install_queue.clear()
|
||||
self._log(f"\n=== Ошибка во время выполнения операций (код: {exit_code}). ===", "red")
|
||||
self._show_message_box(
|
||||
"Ошибка",
|
||||
"Произошла ошибка во время выполнения операций.\nПодробности смотрите в логе.",
|
||||
QMessageBox.Warning,
|
||||
{"buttons": {"OK": QMessageBox.AcceptRole}}
|
||||
QMessageBox.warning(
|
||||
self, "Ошибка", "Произошла ошибка во время выполнения операций.\nПодробности смотрите в логе."
|
||||
)
|
||||
self.log_output.setCurrentCharFormat(QTextCharFormat())
|
||||
self.apply_button.setEnabled(True)
|
||||
self.close_button.setEnabled(True)
|
||||
return
|
||||
|
||||
if self._install_queue:
|
||||
self._process_next_install_task()
|
||||
return
|
||||
|
||||
self._log("\n=== Все операции успешно завершены ===")
|
||||
self.apply_button.setEnabled(True)
|
||||
self.reinstall_button.setEnabled(False)
|
||||
@@ -497,16 +521,14 @@ class WinetricksManagerDialog(QDialog):
|
||||
|
||||
def clear_winetricks_cache(self):
|
||||
"""Запускает очистку кэша Winetricks."""
|
||||
reply = self._show_message_box(
|
||||
if not DialogUtils.confirm(
|
||||
self,
|
||||
"Очистка кэша Winetricks",
|
||||
"Вы собираетесь удалить все скачанные установщики и списки компонентов Winetricks.\n\n"
|
||||
"Это действие может потребоваться, если у вас возникают проблемы со скачиванием или установкой компонентов.\n\n"
|
||||
"Это действие может потребоваться, если у вас возникают проблемы "
|
||||
"со скачиванием или установкой компонентов.\n\n"
|
||||
"Продолжить?",
|
||||
QMessageBox.Question,
|
||||
{"buttons": {"Да": QMessageBox.YesRole, "Нет": QMessageBox.NoRole}, "default": "Нет"}
|
||||
)
|
||||
|
||||
if reply != "Да":
|
||||
):
|
||||
return
|
||||
|
||||
self.tabs.setEnabled(False)
|
||||
@@ -518,10 +540,10 @@ class WinetricksManagerDialog(QDialog):
|
||||
self.log_output.clear()
|
||||
|
||||
self.cache_clear_process = QProcess(self)
|
||||
self.cache_clear_process.setProcessChannelMode(QProcess.MergedChannels)
|
||||
ProcessUtils.prepare_process(self.cache_clear_process)
|
||||
|
||||
def handle_output():
|
||||
output = self.cache_clear_process.readAll().data().decode('utf-8', 'ignore').strip()
|
||||
output = self.cache_clear_process.readAll().data().decode("utf-8", "ignore").strip()
|
||||
if output:
|
||||
self._log(output)
|
||||
|
||||
@@ -529,7 +551,7 @@ class WinetricksManagerDialog(QDialog):
|
||||
if exit_code == 0:
|
||||
self.is_reloading_after_cache_clear = True
|
||||
self.category_statuses.clear()
|
||||
os.makedirs(self.cache_dir, exist_ok=True)
|
||||
self.cache_service.ensure_cache_dir()
|
||||
self._log("Обновление списков...")
|
||||
self.initial_states.clear()
|
||||
self.load_all_categories()
|
||||
@@ -540,11 +562,15 @@ class WinetricksManagerDialog(QDialog):
|
||||
self.clear_cache_button.setEnabled(True)
|
||||
self.close_button.setEnabled(True)
|
||||
self._update_ui_state()
|
||||
self.cache_clear_process.deleteLater()
|
||||
|
||||
self.cache_clear_process.readyRead.connect(handle_output)
|
||||
self.cache_clear_process.finished.connect(handle_finish)
|
||||
|
||||
winehelper_path = self.parent().winehelper_path if hasattr(self.parent(), 'winehelper_path') else EnvironmentConfig.RUN_SCRIPT
|
||||
if self.parent() and hasattr(self.parent(), "winehelper_path"):
|
||||
winehelper_path = self.parent().winehelper_path
|
||||
else:
|
||||
winehelper_path = EnvironmentConfig.RUN_SCRIPT
|
||||
args = ["clear-winetricks-cache", "--force"]
|
||||
|
||||
self._log(f"Выполнение: {shlex.quote(winehelper_path)} {' '.join(args)}\n")
|
||||
@@ -552,48 +578,51 @@ class WinetricksManagerDialog(QDialog):
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Обрабатывает закрытие окна."""
|
||||
if self.apply_process and self.apply_process.state() == QProcess.Running:
|
||||
reply = self._show_message_box(
|
||||
'Подтверждение',
|
||||
"Процесс установки еще не завершен. Вы уверены, что хотите прервать его?",
|
||||
QMessageBox.Question,
|
||||
{"buttons": {"Да": QMessageBox.YesRole, "Нет": QMessageBox.NoRole}, "default": "Нет"}
|
||||
)
|
||||
if reply == "Да":
|
||||
if self.apply_process and self.apply_process.state() == QProcess.ProcessState.Running:
|
||||
if DialogUtils.confirm(
|
||||
self, "Подтверждение", "Процесс установки еще не завершен. Вы уверены, что хотите прервать его?"
|
||||
):
|
||||
self.user_cancelled = True
|
||||
self.log_output.append("\n=== Прерывание установки... Ожидание завершения процесса... ===")
|
||||
self._log("=== Прерывание установки... Ожидание завершения процесса... ===")
|
||||
self.apply_process.terminate()
|
||||
event.ignore()
|
||||
else:
|
||||
event.ignore()
|
||||
event.ignore()
|
||||
else:
|
||||
# Корректно завершаем фоновые процессы (загрузка категорий, очистка кэша)
|
||||
for process in list(self.processes.values()):
|
||||
if process.state() == QProcess.ProcessState.Running:
|
||||
process.terminate()
|
||||
# cache_clear_process после deleteLater может стать удалённым
|
||||
# C++-объектом, поэтому защищаемся проверкой живости
|
||||
cache_clear_process = self.cache_clear_process
|
||||
if (
|
||||
cache_clear_process is not None
|
||||
and not DialogUtils.is_widget_deleted(cache_clear_process)
|
||||
and cache_clear_process.state() == QProcess.ProcessState.Running
|
||||
):
|
||||
cache_clear_process.terminate()
|
||||
event.accept()
|
||||
|
||||
def _show_message_box(self, title, text, icon, config):
|
||||
"""Централизованный метод для создания и показа QMessageBox."""
|
||||
msg_box = QMessageBox(self)
|
||||
msg_box.setWindowTitle(title)
|
||||
msg_box.setText(text)
|
||||
msg_box.setIcon(icon)
|
||||
|
||||
buttons = {}
|
||||
for btn_text, role in config.get("buttons", {}).items():
|
||||
buttons[btn_text] = msg_box.addButton(btn_text, role)
|
||||
|
||||
default_btn_text = config.get("default")
|
||||
if default_btn_text and default_btn_text in buttons:
|
||||
msg_box.setDefaultButton(buttons[default_btn_text])
|
||||
|
||||
msg_box.exec_()
|
||||
|
||||
clicked_button = msg_box.clickedButton()
|
||||
return clicked_button.text() if clicked_button else None
|
||||
|
||||
def _log(self, message, color=None):
|
||||
"""Добавляет сообщение в лог с возможностью указания цвета."""
|
||||
"""
|
||||
Добавляет сообщение в лог с возможностью указания цвета.
|
||||
|
||||
Args:
|
||||
message: Текст сообщения
|
||||
color: Цвет текста (например, "red")
|
||||
"""
|
||||
if color:
|
||||
self.log_output.append(f'<span style="color:{color};">{message}</span>')
|
||||
self.log_output.setCurrentCharFormat(QTextCharFormat())
|
||||
else:
|
||||
self.log_output.append(message)
|
||||
self.log_output.moveCursor(QTextCursor.End)
|
||||
self.log_output.moveCursor(QTextCursor.MoveOperation.End)
|
||||
if message.strip():
|
||||
# Зеркалируем в консоль через логгер процесса: консоль — единое
|
||||
# зеркало содержимого диалога. Ведущие/хвостовые переносы
|
||||
# (отступ в виджете) не логируем, иначе префикс строки уйдёт
|
||||
# на отдельную строку
|
||||
log_text = message.strip("\n")
|
||||
if color == "red":
|
||||
process_logger.error("%s", log_text)
|
||||
else:
|
||||
process_logger.info("%s", log_text)
|
||||
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
Единый логгер приложения.
|
||||
|
||||
По умолчанию пишет в stdout — виден при запуске GUI из терминала
|
||||
(``./winehelper gui``). При запуске из меню терминала нет, вывод
|
||||
просто уходит в никуда, ничего не ломая.
|
||||
|
||||
Уровень задаётся переменной окружения ``WH_LOG_LEVEL``
|
||||
(debug/info/warning/error, по умолчанию info).
|
||||
|
||||
Использование в модулях::
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
logger = get_logger(__name__)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
|
||||
DEFAULT_LEVEL = "INFO"
|
||||
|
||||
# ANSI-коды цветов для уровней логирования
|
||||
_LEVEL_COLORS = {
|
||||
"DEBUG": "\033[36m", # голубой
|
||||
"INFO": "\033[32m", # зелёный
|
||||
"WARNING": "\033[33m", # жёлтый
|
||||
"ERROR": "\033[31m", # красный
|
||||
"CRITICAL": "\033[1;31m", # жирный красный
|
||||
}
|
||||
_COLOR_RESET = "\033[0m"
|
||||
|
||||
|
||||
class ColoredFormatter(logging.Formatter):
|
||||
"""Форматтер с цветной подсветкой уровня логирования."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
color = _LEVEL_COLORS.get(record.levelname, "")
|
||||
if color:
|
||||
record.levelname = f"{color}{record.levelname}{_COLOR_RESET}"
|
||||
return super().format(record)
|
||||
|
||||
|
||||
# Строка прогресса в интерактивном терминале выводится как обычная
|
||||
# строка лога (с префиксом [время] [уровень] [process]) с анимацией:
|
||||
# обновления перекрывают текущую строку (\r). Чтобы строка с префиксом
|
||||
# гарантированно помещалась в ширину терминала, бар укорачивается.
|
||||
# _bar_pending — в stdout ещё не записан перевод строки после строки прогресса;
|
||||
# _bar_prefix — префикс текущей строки прогресса (для перекрытия всей строки)
|
||||
_bar_pending = False
|
||||
_bar_prefix = ""
|
||||
|
||||
# ANSI-последовательности (не занимают видимых колонок)
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
def _progress_prefix() -> str:
|
||||
"""Префикс строки прогресса — идентичен префиксу обычной строки лога."""
|
||||
ts = time.strftime("%H:%M:%S")
|
||||
if sys.stdout.isatty():
|
||||
level = f"{_LEVEL_COLORS['INFO']}INFO{_COLOR_RESET}"
|
||||
else:
|
||||
level = "INFO"
|
||||
return f"[{ts}] [{level}] [process] "
|
||||
|
||||
|
||||
def _terminal_columns() -> int:
|
||||
"""Ширина терминала в колонках (с запасом при недоступности)."""
|
||||
try:
|
||||
return shutil.get_terminal_size(fallback=(80, 24)).columns
|
||||
except (OSError, ValueError):
|
||||
return 80
|
||||
|
||||
|
||||
def _fit_to_terminal(text: str, prefix: str) -> str:
|
||||
"""
|
||||
Укорачивает текст так, чтобы префикс + текст помещались в ширину
|
||||
терминала (обрезка слева: проценты находятся в конце и всегда видны).
|
||||
"""
|
||||
visual_prefix = len(_ANSI_RE.sub("", prefix))
|
||||
available = _terminal_columns() - visual_prefix - 1
|
||||
if available < 4:
|
||||
available = 4
|
||||
if len(text) > available:
|
||||
return text[-available:]
|
||||
return text
|
||||
|
||||
|
||||
def console_progress_write(text: str) -> None:
|
||||
"""
|
||||
Пишет строку прогресса в консоль.
|
||||
|
||||
В интерактивный терминал (tty) — как строка лога с префиксом,
|
||||
перекрытием текущей строки (анимация); бар укорачивается, чтобы
|
||||
строка не переносилась. В перенаправленный вывод (не tty) —
|
||||
строкой лога, чтобы в файле была полная история.
|
||||
"""
|
||||
global _bar_pending, _bar_prefix
|
||||
if sys.stdout.isatty():
|
||||
if not _bar_pending:
|
||||
_bar_prefix = _progress_prefix()
|
||||
sys.stdout.write(_bar_prefix + _fit_to_terminal(text, _bar_prefix) + "\x1b[K")
|
||||
else:
|
||||
sys.stdout.write("\r" + _bar_prefix + _fit_to_terminal(text, _bar_prefix) + "\x1b[K")
|
||||
sys.stdout.flush()
|
||||
_bar_pending = True
|
||||
else:
|
||||
get_logger("process").info("%s", text)
|
||||
|
||||
|
||||
def console_progress_line(text: str) -> None:
|
||||
"""
|
||||
Пишет завершённую строку прогресса (например, итоговое состояние бара).
|
||||
|
||||
В tty — как строка лога с префиксом: если строка прогресса открыта,
|
||||
перекрытием той же строки (весь прогресс остаётся на одной строке).
|
||||
В перенаправленный вывод — строкой лога.
|
||||
"""
|
||||
global _bar_pending, _bar_prefix
|
||||
if sys.stdout.isatty():
|
||||
if _bar_pending:
|
||||
sys.stdout.write("\r" + _bar_prefix + _fit_to_terminal(text, _bar_prefix) + "\n")
|
||||
else:
|
||||
prefix = _progress_prefix()
|
||||
sys.stdout.write(prefix + _fit_to_terminal(text, prefix) + "\n")
|
||||
sys.stdout.flush()
|
||||
_bar_pending = False
|
||||
_bar_prefix = ""
|
||||
else:
|
||||
get_logger("process").info("%s", text)
|
||||
|
||||
|
||||
def console_progress_end() -> None:
|
||||
"""Завершает строку прогресса переводом строки в tty."""
|
||||
global _bar_pending, _bar_prefix
|
||||
if _bar_pending:
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.flush()
|
||||
_bar_pending = False
|
||||
_bar_prefix = ""
|
||||
|
||||
|
||||
class _BarAwareStreamHandler(logging.StreamHandler):
|
||||
"""StreamHandler, завершающий незавершённую строку прогресса перед записью."""
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
global _bar_pending, _bar_prefix
|
||||
if _bar_pending:
|
||||
try:
|
||||
self.stream.write("\n")
|
||||
self.stream.flush()
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
_bar_pending = False
|
||||
_bar_prefix = ""
|
||||
super().emit(record)
|
||||
|
||||
|
||||
def setup_logger(level: str | None = None) -> None:
|
||||
"""
|
||||
Настраивает корневой логгер приложения.
|
||||
|
||||
Args:
|
||||
level: Уровень логирования (debug/info/warning/error).
|
||||
Если не задан, берётся из WH_LOG_LEVEL, иначе info.
|
||||
"""
|
||||
if level is None:
|
||||
level = os.environ.get("WH_LOG_LEVEL", DEFAULT_LEVEL)
|
||||
|
||||
log_level = getattr(logging, level.upper(), logging.INFO)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
|
||||
handler = _BarAwareStreamHandler(sys.stdout)
|
||||
|
||||
log_format = "[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s"
|
||||
if sys.stdout.isatty():
|
||||
formatter: logging.Formatter = ColoredFormatter(log_format, datefmt="%H:%M:%S")
|
||||
else:
|
||||
formatter = logging.Formatter(log_format, datefmt="%H:%M:%S")
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
root_logger.setLevel(log_level)
|
||||
root_logger.addHandler(handler)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Возвращает логгер для модуля."""
|
||||
return logging.getLogger(name)
|
||||
|
||||
|
||||
def log_info_always(logger: logging.Logger, msg: str, *args) -> None:
|
||||
"""
|
||||
Пишет INFO-сообщение независимо от настроенного уровня логирования.
|
||||
|
||||
Для пользовательски важных строк (стартовая строка с binding'ом,
|
||||
подтверждение смены уровня логирования): они не должны ни теряться
|
||||
на уровнях warning/error, ни маскироваться под DEBUG на уровне debug.
|
||||
Уровень корневого логгера временно опускается до INFO и сразу
|
||||
возвращается обратно.
|
||||
"""
|
||||
root_logger = logging.getLogger()
|
||||
configured_level = root_logger.level
|
||||
root_logger.setLevel(logging.INFO)
|
||||
logger.info(msg, *args)
|
||||
root_logger.setLevel(configured_level)
|
||||
@@ -1,13 +1,3 @@
|
||||
"""
|
||||
Парсеры для winehelper.
|
||||
"""
|
||||
from winehelperqt.parsers.sha256_parser import Sha256Parser
|
||||
from winehelperqt.parsers.winetricks_parser import WinetricksOutputParser, WinetricksItem
|
||||
from winehelperqt.parsers.script_parser import ScriptParser
|
||||
|
||||
__all__ = [
|
||||
'Sha256Parser',
|
||||
'WinetricksOutputParser',
|
||||
'WinetricksItem',
|
||||
'ScriptParser',
|
||||
]
|
||||
@@ -1,99 +1,121 @@
|
||||
"""
|
||||
Парсер для извлечения информации из скриптов установки.
|
||||
"""
|
||||
|
||||
import shlex
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.utils.export_utils import ExportUtils
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ScriptParser:
|
||||
"""Утилитарный класс для парсинга информации из скриптов установки."""
|
||||
|
||||
@staticmethod
|
||||
def extract_icons_from_script(script_path):
|
||||
def parse_script(script_path):
|
||||
"""
|
||||
Извлекает иконку для скрипта.
|
||||
Извлекает всю информацию из скрипта установки за один проход по файлу.
|
||||
|
||||
Сначала ищет переменную 'export ICONS_BUTTON_GUI=', если не находит,
|
||||
то ищет 'export PROG_ICON=', затем ищет вызовы 'create_desktop'.
|
||||
Извлекает: PROG_NAME, PROG_URL, описание (# info_ru:) и иконки
|
||||
(приоритет: ICONS_BUTTON_GUI > PROG_ICON > create_desktop).
|
||||
|
||||
Args:
|
||||
script_path: Путь к скрипту установки
|
||||
|
||||
Returns:
|
||||
Список имен иконок
|
||||
Словарь с ключами prog_name, url, description, icons.
|
||||
При ошибке чтения — значения по умолчанию (как у старых
|
||||
отдельных методов: None / "Ошибка чтения файла: ..." / []).
|
||||
"""
|
||||
logger.debug("Парсим скрипт установки: %s", script_path)
|
||||
try:
|
||||
with open(script_path, 'r', encoding='utf-8') as f:
|
||||
with open(script_path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
except Exception as e:
|
||||
logger.warning("Не удалось прочитать скрипт %s: %s", script_path, e)
|
||||
return {
|
||||
"prog_name": None,
|
||||
"url": None,
|
||||
"description": f"Ошибка чтения файла: {str(e)}",
|
||||
"icons": [],
|
||||
}
|
||||
|
||||
# Приоритет у ICONS_BUTTON_GUI
|
||||
for line in lines:
|
||||
if line.strip().startswith('export ICONS_BUTTON_GUI='):
|
||||
icon_names_str = line.split('=', 1)[1].strip().strip('"\'')
|
||||
if icon_names_str:
|
||||
return icon_names_str.split()
|
||||
# Переменные: берём первое вхождение каждой (в т.ч. пустое — как раньше
|
||||
# в _extract_variable), описание — первую строку "# info_ru:"
|
||||
prog_name = None
|
||||
prog_url = None
|
||||
description = None
|
||||
prog_name_done = False
|
||||
prog_url_done = False
|
||||
description_done = False
|
||||
|
||||
for line in lines:
|
||||
if line.strip().startswith('export PROG_ICON='):
|
||||
icon_names_str = line.split('=', 1)[1].strip().strip('"\'')
|
||||
if icon_names_str:
|
||||
return icon_names_str.split()
|
||||
for line in lines:
|
||||
if prog_name_done and prog_url_done and description_done:
|
||||
break
|
||||
parsed_name, parsed_value = ExportUtils.parse_line(line)
|
||||
if not prog_name_done and parsed_name == "PROG_NAME":
|
||||
prog_name = parsed_value if parsed_value else None
|
||||
prog_name_done = True
|
||||
if not prog_url_done and parsed_name == "PROG_URL":
|
||||
prog_url = parsed_value if parsed_value else None
|
||||
prog_url_done = True
|
||||
if not description_done and line.startswith("# info_ru:"):
|
||||
description = line.replace("# info_ru:", "").strip()
|
||||
description_done = True
|
||||
|
||||
# Поиск в create_desktop
|
||||
icon_names = []
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
if description is None:
|
||||
description = "Описание отсутствует"
|
||||
|
||||
return {
|
||||
"prog_name": prog_name,
|
||||
"url": prog_url,
|
||||
"description": description,
|
||||
"icons": ScriptParser._extract_icons_from_lines(lines, script_path),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _extract_icons_from_lines(lines, script_path):
|
||||
"""
|
||||
Ищет иконки в уже прочитанных строках скрипта.
|
||||
|
||||
Приоритет: ICONS_BUTTON_GUI > PROG_ICON > имена иконок из вызовов create_desktop.
|
||||
"""
|
||||
for line in lines:
|
||||
if line.strip().startswith("export ICONS_BUTTON_GUI="):
|
||||
icon_names_str = line.split("=", 1)[1].strip().strip("\"'")
|
||||
if icon_names_str:
|
||||
icons = icon_names_str.split()
|
||||
logger.debug("Найдены иконки %s в %s", icons, script_path)
|
||||
return icons
|
||||
|
||||
for line in lines:
|
||||
if line.strip().startswith("export PROG_ICON="):
|
||||
icon_names_str = line.split("=", 1)[1].strip().strip("\"'")
|
||||
if icon_names_str:
|
||||
icons = icon_names_str.split()
|
||||
logger.debug("Найдены иконки %s в %s", icons, script_path)
|
||||
return icons
|
||||
|
||||
# Поиск в create_desktop
|
||||
icon_names = []
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
if "create_desktop" in line:
|
||||
try:
|
||||
parts = shlex.split(line)
|
||||
for i, part in enumerate(parts):
|
||||
if part == "create_desktop":
|
||||
if len(parts) > i + 3:
|
||||
icon_name = parts[i + 3]
|
||||
if icon_name and icon_name.lower() not in ("auto", "nocopy"):
|
||||
icon_names.append(icon_name)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.debug("Пропущена строка create_desktop в %s: %s", script_path, e)
|
||||
continue
|
||||
|
||||
if 'create_desktop' in line:
|
||||
try:
|
||||
parts = shlex.split(line)
|
||||
for i, part in enumerate(parts):
|
||||
if part == 'create_desktop':
|
||||
if len(parts) > i + 3:
|
||||
icon_name = parts[i + 3]
|
||||
if icon_name and icon_name.lower() not in ('auto', 'nocopy'):
|
||||
icon_names.append(icon_name)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
return icon_names
|
||||
except Exception as e:
|
||||
print(f"Ошибка чтения файла для извлечения иконки: {str(e)}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def extract_prog_name_from_script(script_path):
|
||||
"""Извлекает имя программы из строки PROG_NAME= в скрипте"""
|
||||
try:
|
||||
with open(script_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
if line.strip().startswith(('export PROG_NAME=', 'PROG_NAME=')):
|
||||
name = line.split('=', 1)[1].strip().strip('"\'')
|
||||
if name:
|
||||
return name
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Ошибка чтения файла для извлечения PROG_NAME: {str(e)}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def extract_prog_url_from_script(script_path):
|
||||
"""Извлекает URL из строки export PROG_URL= в скрипте"""
|
||||
try:
|
||||
with open(script_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
if line.startswith('export PROG_URL='):
|
||||
return line.replace('export PROG_URL=', '').strip().strip('"\'')
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Ошибка чтения файла для извлечения PROG_URL: {str(e)}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def extract_info_ru(script_path):
|
||||
"""Извлекает информацию из строки # info_ru: в скрипте"""
|
||||
try:
|
||||
with open(script_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
if line.startswith('# info_ru:'):
|
||||
return line.replace('# info_ru:', '').strip()
|
||||
return "Описание отсутствует"
|
||||
except Exception as e:
|
||||
return f"Ошибка чтения файла: {str(e)}"
|
||||
logger.debug("Иконки из create_desktop в %s: %s", script_path, icon_names)
|
||||
return icon_names
|
||||
@@ -2,19 +2,25 @@
|
||||
Парсер для файла sha256sum.list.
|
||||
Извлекает версии Wine и компонентов из файла хешей.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.utils.version_utils import sort_versions
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class Sha256Parser:
|
||||
"""Парсер файла sha256sum.list для извлечения версий пакетов."""
|
||||
|
||||
WINE_ALLOWED_GROUPS = {"WINE WOW64", "WINE AMD64", "WINE I586"}
|
||||
GROUP_HEADER_PATTERN = re.compile(r'^#+\s*(.*?)\s*#*$')
|
||||
HASH_PATTERN = re.compile(r'^[a-f0-9]{64}')
|
||||
ARCHIVE_EXTENSION = '.tar.xz'
|
||||
GROUP_HEADER_PATTERN = re.compile(r"^#+\s*(.*?)\s*#*$")
|
||||
HASH_PATTERN = re.compile(r"^[a-f0-9]{64}")
|
||||
PREFIX_SECTION_START = re.compile(r"^#####\s*PREFIX\s*#####$")
|
||||
SECTION_END_PATTERN = re.compile(r"^#####")
|
||||
ARCHIVE_EXTENSION = ".tar.xz"
|
||||
|
||||
@classmethod
|
||||
def parse_wine_versions(cls, sha256_path):
|
||||
@@ -36,7 +42,7 @@ class Sha256Parser:
|
||||
current_group = None
|
||||
|
||||
try:
|
||||
with open(sha256_path, 'r', encoding='utf-8') as f:
|
||||
with open(sha256_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
@@ -58,12 +64,14 @@ class Sha256Parser:
|
||||
if len(parts) == 2:
|
||||
filename = parts[1]
|
||||
if filename.endswith(cls.ARCHIVE_EXTENSION):
|
||||
version_name = filename[:-len(cls.ARCHIVE_EXTENSION)]
|
||||
version_name = filename[: -len(cls.ARCHIVE_EXTENSION)]
|
||||
versions_data[current_group].append(version_name)
|
||||
|
||||
except IOError:
|
||||
except IOError as e:
|
||||
logger.warning("Не удалось прочитать файл версий %s: %s", sha256_path, e)
|
||||
return {}
|
||||
|
||||
logger.debug("Версии Wine из %s: %s", sha256_path, versions_data)
|
||||
return versions_data
|
||||
|
||||
@classmethod
|
||||
@@ -86,7 +94,7 @@ class Sha256Parser:
|
||||
current_group = None
|
||||
|
||||
try:
|
||||
with open(sha256_path, 'r', encoding='utf-8') as f:
|
||||
with open(sha256_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
@@ -102,12 +110,14 @@ class Sha256Parser:
|
||||
if len(parts) == 2:
|
||||
filename = parts[1]
|
||||
if filename.endswith(cls.ARCHIVE_EXTENSION):
|
||||
version_name = filename[:-len(cls.ARCHIVE_EXTENSION)]
|
||||
version_name = filename[: -len(cls.ARCHIVE_EXTENSION)]
|
||||
versions_data.append(version_name)
|
||||
|
||||
except IOError:
|
||||
except IOError as e:
|
||||
logger.warning("Не удалось прочитать файл версий %s: %s", sha256_path, e)
|
||||
return []
|
||||
|
||||
logger.debug("Версии компонента %s из %s: %s", component_group, sha256_path, versions_data)
|
||||
return versions_data
|
||||
|
||||
@classmethod
|
||||
@@ -122,7 +132,7 @@ class Sha256Parser:
|
||||
Словарь {архитектура: [(имя_префикса, описание)]}, например:
|
||||
{'win32': [('prefix_x86_v1', 'Описание')], 'win64': [...]}
|
||||
"""
|
||||
prepared_prefixes = {'win32': [], 'win64': []}
|
||||
prepared_prefixes = {"win32": [], "win64": []}
|
||||
|
||||
if not os.path.exists(sha256_path):
|
||||
return prepared_prefixes
|
||||
@@ -132,20 +142,16 @@ class Sha256Parser:
|
||||
current_prefix_name = None
|
||||
|
||||
try:
|
||||
with open(sha256_path, 'r', encoding='utf-8') as f:
|
||||
with open(sha256_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
|
||||
if "##### PREFIX #####" in line:
|
||||
if cls.PREFIX_SECTION_START.match(line):
|
||||
in_prefix_section = True
|
||||
continue
|
||||
elif line.startswith("#####"):
|
||||
if cls.SECTION_END_PATTERN.match(line):
|
||||
if current_prefix_name and current_description:
|
||||
cls._add_prefix_to_list(
|
||||
prepared_prefixes,
|
||||
current_prefix_name,
|
||||
current_description
|
||||
)
|
||||
cls._add_prefix_to_list(prepared_prefixes, current_prefix_name, current_description)
|
||||
in_prefix_section = False
|
||||
current_description = ""
|
||||
current_prefix_name = None
|
||||
@@ -156,21 +162,17 @@ class Sha256Parser:
|
||||
|
||||
if cls.HASH_PATTERN.match(line):
|
||||
if current_prefix_name and current_description:
|
||||
cls._add_prefix_to_list(
|
||||
prepared_prefixes,
|
||||
current_prefix_name,
|
||||
current_description
|
||||
)
|
||||
cls._add_prefix_to_list(prepared_prefixes, current_prefix_name, current_description)
|
||||
|
||||
current_description = ""
|
||||
|
||||
parts = line.split(maxsplit=1)
|
||||
if len(parts) == 2:
|
||||
current_prefix_name = parts[1].replace('.tar.xz', '')
|
||||
current_prefix_name = parts[1].replace(".tar.xz", "")
|
||||
else:
|
||||
current_prefix_name = None
|
||||
|
||||
elif line.startswith('# '):
|
||||
elif line.startswith("# "):
|
||||
comment_line = line[2:].strip()
|
||||
if current_description:
|
||||
current_description += "\n" + comment_line
|
||||
@@ -178,17 +180,15 @@ class Sha256Parser:
|
||||
current_description = comment_line
|
||||
|
||||
if current_prefix_name and current_description:
|
||||
cls._add_prefix_to_list(
|
||||
prepared_prefixes,
|
||||
current_prefix_name,
|
||||
current_description
|
||||
)
|
||||
cls._add_prefix_to_list(prepared_prefixes, current_prefix_name, current_description)
|
||||
|
||||
except IOError:
|
||||
return {'win32': [], 'win64': []}
|
||||
except IOError as e:
|
||||
logger.warning("Не удалось прочитать префиксы из %s: %s", sha256_path, e)
|
||||
return {"win32": [], "win64": []}
|
||||
|
||||
cls._add_clean_prefix_option(prepared_prefixes)
|
||||
|
||||
logger.debug("Подготовленные префиксы из %s: %s", sha256_path, prepared_prefixes)
|
||||
return prepared_prefixes
|
||||
|
||||
@classmethod
|
||||
@@ -196,21 +196,19 @@ class Sha256Parser:
|
||||
"""Добавляет префикс в список с определением архитектуры."""
|
||||
arch = None
|
||||
if "_x86_" in prefix_name or "_i586_" in prefix_name:
|
||||
arch = 'win32'
|
||||
arch = "win32"
|
||||
elif "_x64_" in prefix_name or "_amd64_" in prefix_name:
|
||||
arch = 'win64'
|
||||
arch = "win64"
|
||||
|
||||
if arch:
|
||||
prepared_prefixes[arch].append(
|
||||
(prefix_name, description.strip().replace('\\n', '\n'))
|
||||
)
|
||||
prepared_prefixes[arch].append((prefix_name, description.strip().replace("\\n", "\n")))
|
||||
|
||||
@classmethod
|
||||
def _add_clean_prefix_option(cls, prepared_prefixes):
|
||||
"""Добавляет опцию 'Чистый префикс' для обеих архитектур."""
|
||||
clean_prefix_description = 'Создать чистый префикс без дополнительных библиотек'
|
||||
prepared_prefixes['win32'].insert(0, ('none', clean_prefix_description))
|
||||
prepared_prefixes['win64'].insert(0, ('none', clean_prefix_description))
|
||||
clean_prefix_description = "Создать чистый префикс без дополнительных библиотек"
|
||||
prepared_prefixes["win32"].insert(0, ("none", clean_prefix_description))
|
||||
prepared_prefixes["win64"].insert(0, ("none", clean_prefix_description))
|
||||
|
||||
@classmethod
|
||||
def get_installed_versions(cls, dist_path):
|
||||
@@ -227,10 +225,10 @@ class Sha256Parser:
|
||||
return []
|
||||
|
||||
try:
|
||||
versions = [
|
||||
name for name in os.listdir(dist_path)
|
||||
if os.path.isdir(os.path.join(dist_path, name))
|
||||
]
|
||||
return sort_versions(versions, reverse=True)
|
||||
except OSError:
|
||||
versions = [name for name in os.listdir(dist_path) if os.path.isdir(os.path.join(dist_path, name))]
|
||||
sorted_versions = sort_versions(versions, reverse=True)
|
||||
logger.debug("Установленные версии в %s: %s", dist_path, sorted_versions)
|
||||
return sorted_versions
|
||||
except OSError as e:
|
||||
logger.warning("Не удалось прочитать директорию dist %s: %s", dist_path, e)
|
||||
return []
|
||||
@@ -2,9 +2,14 @@
|
||||
Парсер для вывода утилиты Winetricks.
|
||||
Извлекает списки компонентов и информацию об установленных пакетах.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class WinetricksItem:
|
||||
"""Элемент списка компонентов Winetricks."""
|
||||
@@ -21,23 +26,16 @@ class WinetricksOutputParser:
|
||||
|
||||
LINE_PATTERN = re.compile(r"^\s*(?:\[(.)]\s+)?([^\s]+)\s*(.*)")
|
||||
|
||||
DLLS_BLACKLIST_PATTERN = re.compile(
|
||||
r'^(dont_use|dxvk|vkd3d|galliumnine|faudio|foundation)',
|
||||
re.IGNORECASE
|
||||
)
|
||||
FONTS_BLACKLIST_PATTERN = re.compile(
|
||||
r'^(dont_use)',
|
||||
re.IGNORECASE
|
||||
)
|
||||
DLLS_BLACKLIST_PATTERN = re.compile(r"^(dont_use|dxvk|vkd3d|galliumnine|faudio|foundation)", re.IGNORECASE)
|
||||
FONTS_BLACKLIST_PATTERN = re.compile(r"^(dont_use)", re.IGNORECASE)
|
||||
SETTINGS_BLACKLIST_PATTERN = re.compile(
|
||||
r'^(vista|alldlls|autostart_|bad|good|win|videomemory|vd=|isolate_home)',
|
||||
re.IGNORECASE
|
||||
r"^(vista|alldlls|autostart_|bad|good|win|videomemory|vd=|isolate_home)", re.IGNORECASE
|
||||
)
|
||||
|
||||
DOWNLOAD_AVAILABLE_PATTERN = re.compile(r'\[\s*доступно для скачивания[^]]*\]')
|
||||
IN_CACHE_PATTERN = re.compile(r'\[\s*в кэше\s*\]')
|
||||
DOWNLOAD_AVAILABLE_PATTERN = re.compile(r"\[\s*доступно для скачивания[^]]*\]")
|
||||
IN_CACHE_PATTERN = re.compile(r"\[\s*в кэше\s*\]")
|
||||
|
||||
SERVICE_WORDS = {'executing', 'using', 'warning:', '#', 'export'}
|
||||
SERVICE_WORDS = {"executing", "using", "warning:", "#", "export"}
|
||||
|
||||
@classmethod
|
||||
def parse_installed_verbs(cls, log_path):
|
||||
@@ -56,13 +54,15 @@ class WinetricksOutputParser:
|
||||
return installed_verbs
|
||||
|
||||
try:
|
||||
with open(log_path, 'r', encoding='utf-8') as f:
|
||||
with open(log_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
verb = line.split('#', 1)[0].strip()
|
||||
# Учитываем экранированный #: разделяем только по неэкранированному #
|
||||
parts = re.split(r"(?<!\\)#", line)
|
||||
verb = parts[0].replace("\\#", "#").strip()
|
||||
if verb:
|
||||
installed_verbs.add(verb)
|
||||
except Exception as e:
|
||||
print(f"Предупреждение: не удалось прочитать {log_path}: {e}")
|
||||
logger.warning("Не удалось прочитать %s: %s", log_path, e)
|
||||
|
||||
return installed_verbs
|
||||
|
||||
@@ -96,14 +96,11 @@ class WinetricksOutputParser:
|
||||
description = cls._clean_description(description)
|
||||
|
||||
is_checked = name in installed_verbs
|
||||
item_text = f"{name.ljust(27)}{description.strip()}"
|
||||
item_text = f"{name.ljust(24)}{description.strip()}"
|
||||
|
||||
items.append(WinetricksItem(
|
||||
name=name,
|
||||
description=description.strip(),
|
||||
is_installed=is_checked,
|
||||
item_text=item_text
|
||||
))
|
||||
items.append(
|
||||
WinetricksItem(name=name, description=description.strip(), is_installed=is_checked, item_text=item_text)
|
||||
)
|
||||
|
||||
return items, found_items
|
||||
|
||||
@@ -111,9 +108,9 @@ class WinetricksOutputParser:
|
||||
def _get_blacklist_pattern(cls, category):
|
||||
"""Возвращает шаблон blacklist для указанной категории."""
|
||||
patterns = {
|
||||
'dlls': cls.DLLS_BLACKLIST_PATTERN,
|
||||
'fonts': cls.FONTS_BLACKLIST_PATTERN,
|
||||
'settings': cls.SETTINGS_BLACKLIST_PATTERN
|
||||
"dlls": cls.DLLS_BLACKLIST_PATTERN,
|
||||
"fonts": cls.FONTS_BLACKLIST_PATTERN,
|
||||
"settings": cls.SETTINGS_BLACKLIST_PATTERN,
|
||||
}
|
||||
return patterns.get(category)
|
||||
|
||||
@@ -129,10 +126,10 @@ class WinetricksOutputParser:
|
||||
Returns:
|
||||
True если компонент допустим, False иначе
|
||||
"""
|
||||
if '/' in name or '\\' in name:
|
||||
if "/" in name or "\\" in name:
|
||||
return False
|
||||
|
||||
if name.lower() in cls.SERVICE_WORDS or name.endswith(':'):
|
||||
if name.lower() in cls.SERVICE_WORDS or name.endswith(":"):
|
||||
return False
|
||||
|
||||
blacklist_pattern = cls._get_blacklist_pattern(category)
|
||||
@@ -152,25 +149,6 @@ class WinetricksOutputParser:
|
||||
Returns:
|
||||
Очищенное описание
|
||||
"""
|
||||
description = cls.DOWNLOAD_AVAILABLE_PATTERN.sub('', description)
|
||||
description = cls.IN_CACHE_PATTERN.sub('', description)
|
||||
description = cls.DOWNLOAD_AVAILABLE_PATTERN.sub("", description)
|
||||
description = cls.IN_CACHE_PATTERN.sub("", description)
|
||||
return description.strip()
|
||||
|
||||
@classmethod
|
||||
def get_blacklist_pattern_for_category(cls, category):
|
||||
"""
|
||||
Возвращает строковое представление blacklist паттерна для категории.
|
||||
Полезно для отладки и логирования.
|
||||
|
||||
Args:
|
||||
category: Категория ('dlls', 'fonts', 'settings')
|
||||
|
||||
Returns:
|
||||
Строковое представление паттерна или None
|
||||
"""
|
||||
patterns = {
|
||||
'dlls': cls.DLLS_BLACKLIST_PATTERN.pattern,
|
||||
'fonts': cls.FONTS_BLACKLIST_PATTERN.pattern,
|
||||
'settings': cls.SETTINGS_BLACKLIST_PATTERN.pattern
|
||||
}
|
||||
return patterns.get(category)
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Слой совместимости PyQt6/PyQt5.
|
||||
|
||||
Весь GUI-код проекта импортирует Qt только отсюда:
|
||||
|
||||
from winehelperqt.qt_compat import Qt, QWidget, QT6
|
||||
|
||||
Выбор binding'а (приоритет сверху вниз):
|
||||
|
||||
1. Переменная окружения WH_QT_BINDING=pyqt5|pyqt6 — для тестирования.
|
||||
2. Настройка «Qt binding» во вкладке «Настройки» GUI
|
||||
(ключ qt_binding в ~/.config/winehelper/logging.conf): auto|pyqt6|pyqt5.
|
||||
3. auto — PyQt6, если он установлен и рабочий (p11, Sisyphus),
|
||||
иначе PyQt5 (p10, где PyQt6 отсутствует в репозитории).
|
||||
|
||||
В strict-режимах (pyqt5/pyqt6) fallback отключён: если выбранный binding
|
||||
недоступен, при импорте этого модуля поднимается ImportError с понятным
|
||||
сообщением (его перехватывает winehelper_gui.py и завершается с ошибкой).
|
||||
|
||||
Внешние прослойки (QtPy) не используются: в p10 лежит слишком старая версия.
|
||||
|
||||
Код проекта пишется в общем для обоих binding'ов стиле: enum'ы только
|
||||
в scoped-форме (Qt.AlignmentFlag.AlignCenter), exec() вместо exec_(),
|
||||
сравнения enum с int — через Qt.CheckState(state) == ...
|
||||
"""
|
||||
|
||||
import configparser
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
VALID_BINDINGS = ("auto", "pyqt6", "pyqt5")
|
||||
|
||||
# Тот же файл, что использует GlobalSettingsService; читаем напрямую,
|
||||
# т.к. binding выбирается при импорте, до инициализации сервисов GUI.
|
||||
_CONFIG_PATH = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "winehelper" / "logging.conf"
|
||||
|
||||
|
||||
def _read_binding_choice() -> str:
|
||||
"""Читает выбранный binding: WH_QT_BINDING > конфиг GUI > auto."""
|
||||
forced = os.environ.get("WH_QT_BINDING", "").lower().strip()
|
||||
if forced in ("pyqt5", "pyqt6"):
|
||||
return forced
|
||||
try:
|
||||
config = configparser.ConfigParser()
|
||||
config.read(_CONFIG_PATH, encoding="utf-8")
|
||||
saved = config["gui"].get("qt_binding", "auto").lower().strip()
|
||||
if saved in VALID_BINDINGS:
|
||||
return saved
|
||||
except (configparser.Error, OSError, KeyError):
|
||||
pass
|
||||
return "auto"
|
||||
|
||||
|
||||
BINDING = _read_binding_choice()
|
||||
|
||||
QT6 = False
|
||||
_loaded = False
|
||||
_import_errors = []
|
||||
|
||||
if BINDING in ("auto", "pyqt6"):
|
||||
try:
|
||||
from PyQt6 import sip
|
||||
from PyQt6.QtCore import (
|
||||
QEasingCurve,
|
||||
QObject,
|
||||
QProcess,
|
||||
QProcessEnvironment,
|
||||
QPropertyAnimation,
|
||||
QSettings,
|
||||
QSize,
|
||||
Qt,
|
||||
QTimer,
|
||||
pyqtSignal,
|
||||
)
|
||||
from PyQt6.QtGui import (
|
||||
QColor,
|
||||
QCursor,
|
||||
QFont,
|
||||
QFontDatabase,
|
||||
QIcon,
|
||||
QPainter,
|
||||
QPalette,
|
||||
QPixmap,
|
||||
QTextCharFormat,
|
||||
QTextCursor,
|
||||
)
|
||||
from PyQt6.QtNetwork import QLocalServer, QLocalSocket
|
||||
from PyQt6.QtWidgets import (
|
||||
QAbstractButton,
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QStackedWidget,
|
||||
QSystemTrayIcon,
|
||||
QTabBar,
|
||||
QTabWidget,
|
||||
QTextBrowser,
|
||||
QTextEdit,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
QT6 = True
|
||||
_loaded = True
|
||||
except ImportError as e:
|
||||
_import_errors.append(f"PyQt6: {e}")
|
||||
|
||||
if not _loaded and BINDING in ("auto", "pyqt5"):
|
||||
try:
|
||||
from PyQt5 import sip
|
||||
from PyQt5.QtCore import (
|
||||
QEasingCurve,
|
||||
QObject,
|
||||
QProcess,
|
||||
QProcessEnvironment,
|
||||
QPropertyAnimation,
|
||||
QSettings,
|
||||
QSize,
|
||||
Qt,
|
||||
QTimer,
|
||||
pyqtSignal,
|
||||
)
|
||||
from PyQt5.QtGui import (
|
||||
QColor,
|
||||
QCursor,
|
||||
QFont,
|
||||
QFontDatabase,
|
||||
QIcon,
|
||||
QPainter,
|
||||
QPalette,
|
||||
QPixmap,
|
||||
QTextCharFormat,
|
||||
QTextCursor,
|
||||
)
|
||||
from PyQt5.QtNetwork import QLocalServer, QLocalSocket
|
||||
from PyQt5.QtWidgets import (
|
||||
QAbstractButton,
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QStackedWidget,
|
||||
QSystemTrayIcon,
|
||||
QTabBar,
|
||||
QTabWidget,
|
||||
QTextBrowser,
|
||||
QTextEdit,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
_loaded = True
|
||||
except ImportError as e:
|
||||
_import_errors.append(f"PyQt5: {e}")
|
||||
|
||||
if not _loaded:
|
||||
details = "; ".join(str(e) for e in _import_errors)
|
||||
if BINDING == "auto":
|
||||
raise ImportError(
|
||||
"Не найден ни PyQt6, ни PyQt5. Установите python3-module-PyQt6 "
|
||||
f"(p11, Sisyphus) или python3-module-PyQt5 (p10). Детали: {details}"
|
||||
)
|
||||
raise ImportError(f"В настройках WineHelper выбран {BINDING}, но он недоступен. Детали: {details}")
|
||||
|
||||
CURRENT_BINDING = "pyqt6" if QT6 else "pyqt5"
|
||||
|
||||
|
||||
def available_bindings() -> list:
|
||||
"""Возвращает список установленных binding'ов ('pyqt5', 'pyqt6') без их загрузки."""
|
||||
result = []
|
||||
if importlib.util.find_spec("PyQt5") is not None:
|
||||
result.append("pyqt5")
|
||||
if importlib.util.find_spec("PyQt6") is not None and importlib.util.find_spec("PyQt6.sip") is not None:
|
||||
result.append("pyqt6")
|
||||
return result
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BINDING",
|
||||
"CURRENT_BINDING",
|
||||
"QAbstractButton",
|
||||
"QApplication",
|
||||
"QCheckBox",
|
||||
"QColor",
|
||||
"QComboBox",
|
||||
"QCursor",
|
||||
"QDialog",
|
||||
"QDialogButtonBox",
|
||||
"QEasingCurve",
|
||||
"QFileDialog",
|
||||
"QFont",
|
||||
"QFontDatabase",
|
||||
"QFormLayout",
|
||||
"QFrame",
|
||||
"QGridLayout",
|
||||
"QGroupBox",
|
||||
"QHBoxLayout",
|
||||
"QIcon",
|
||||
"QInputDialog",
|
||||
"QLabel",
|
||||
"QLineEdit",
|
||||
"QListWidget",
|
||||
"QListWidgetItem",
|
||||
"QLocalServer",
|
||||
"QLocalSocket",
|
||||
"QMainWindow",
|
||||
"QMenu",
|
||||
"QMessageBox",
|
||||
"QObject",
|
||||
"QPainter",
|
||||
"QPalette",
|
||||
"QPixmap",
|
||||
"QProcess",
|
||||
"QProcessEnvironment",
|
||||
"QProgressBar",
|
||||
"QPropertyAnimation",
|
||||
"QPushButton",
|
||||
"QRadioButton",
|
||||
"QScrollArea",
|
||||
"QSettings",
|
||||
"QSize",
|
||||
"QSizePolicy",
|
||||
"QStackedWidget",
|
||||
"QSystemTrayIcon",
|
||||
"QT6",
|
||||
"QTabBar",
|
||||
"QTabWidget",
|
||||
"QTextBrowser",
|
||||
"QTextCharFormat",
|
||||
"QTextCursor",
|
||||
"QTextEdit",
|
||||
"QTimer",
|
||||
"QToolButton",
|
||||
"QVBoxLayout",
|
||||
"QWidget",
|
||||
"Qt",
|
||||
"VALID_BINDINGS",
|
||||
"available_bindings",
|
||||
"pyqtSignal",
|
||||
"sip",
|
||||
]
|
||||
@@ -1,27 +1,3 @@
|
||||
"""
|
||||
Сервисы для winehelper GUI.
|
||||
"""
|
||||
from winehelperqt.services.winetricks_service import WinetricksCacheService, WinetricksLogReader
|
||||
from winehelperqt.services.prefix_manager import PrefixManager
|
||||
from winehelperqt.services.process_handler import ProcessHandler, CommandRunner
|
||||
from winehelperqt.services.desktop_file_manager import DesktopFileManager
|
||||
from winehelperqt.services.application_installer import ApplicationInstaller
|
||||
from winehelperqt.services.script_manager import ScriptManager, ScriptInfo
|
||||
from winehelperqt.services.installation_manager import InstallationManager
|
||||
from winehelperqt.services.app_launcher import AppLauncher
|
||||
from winehelperqt.services.settings_manager import SettingsManager
|
||||
|
||||
__all__ = [
|
||||
'WinetricksCacheService',
|
||||
'WinetricksLogReader',
|
||||
'PrefixManager',
|
||||
'ProcessHandler',
|
||||
'CommandRunner',
|
||||
'DesktopFileManager',
|
||||
'ApplicationInstaller',
|
||||
'ScriptManager',
|
||||
'ScriptInfo',
|
||||
'InstallationManager',
|
||||
'AppLauncher',
|
||||
'SettingsManager',
|
||||
]
|
||||
@@ -5,17 +5,20 @@
|
||||
- Запуск приложений из .desktop файлов
|
||||
- Остановка приложений
|
||||
- Мониторинг запущенных приложений
|
||||
- Создание логов запуска (в обычном и debug режиме)
|
||||
- Запуск в debug-режиме (WINEDEBUG)
|
||||
"""
|
||||
import os
|
||||
|
||||
import shlex
|
||||
from functools import partial
|
||||
from typing import Dict, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from PyQt5.QtCore import QProcess, QProcessEnvironment
|
||||
|
||||
from winehelperqt.dialogs.winedebug_base import DEFAULT_WINEDEBUG
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.qt_compat import QProcess, QWidget
|
||||
from winehelperqt.utils.process_utils import ProcessUtils
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AppLauncher:
|
||||
"""Сервис для запуска и управления приложениями Wine."""
|
||||
@@ -32,117 +35,127 @@ class AppLauncher:
|
||||
self.user_work_path = user_work_path
|
||||
self._active_processes: Dict[str, QProcess] = {}
|
||||
|
||||
def launch_app(self, desktop_path: str, debug: bool = False) -> QProcess:
|
||||
"""
|
||||
Запускает приложение из .desktop файла.
|
||||
|
||||
Args:
|
||||
desktop_path: Путь к .desktop файлу
|
||||
debug: Запустить в режиме отладки
|
||||
|
||||
Returns:
|
||||
QProcess запущенного приложения
|
||||
"""
|
||||
if not os.path.exists(desktop_path):
|
||||
raise FileNotFoundError(f"Desktop файл не найден: {desktop_path}")
|
||||
|
||||
# Парсим .desktop файл
|
||||
exec_command = self._get_exec_from_desktop(desktop_path)
|
||||
if not exec_command:
|
||||
raise ValueError(f"Не удалось получить команду запуска из: {desktop_path}")
|
||||
|
||||
process = QProcess()
|
||||
process.setProcessChannelMode(QProcess.MergedChannels)
|
||||
|
||||
# В режиме debug включаем полный вывод
|
||||
if debug:
|
||||
env = QProcessEnvironment.systemEnvironment()
|
||||
env.insert("WINEDEBUG", "+loaddll,+seh")
|
||||
process.setProcessEnvironment(env)
|
||||
|
||||
# Запускаем команду
|
||||
args = shlex.split(exec_command)
|
||||
executable = args[0]
|
||||
process.start(executable, args[1:])
|
||||
|
||||
if not process.waitForStarted():
|
||||
raise RuntimeError(f"Не удалось запустить приложение: {desktop_path}")
|
||||
|
||||
# Сохраняем процесс
|
||||
self._active_processes[desktop_path] = process
|
||||
|
||||
# Подключаем обработчик завершения
|
||||
process.finished.connect(
|
||||
lambda: self._on_app_finished(desktop_path, process)
|
||||
)
|
||||
|
||||
return process
|
||||
|
||||
def launch_app_with_log(self, desktop_path: str, debug: bool = False) -> QProcess:
|
||||
"""
|
||||
Запускает приложение с созданием лога.
|
||||
|
||||
Args:
|
||||
desktop_path: Путь к .desktop файлу
|
||||
debug: Создать debug лог
|
||||
|
||||
Returns:
|
||||
QProcess запущенного приложения
|
||||
"""
|
||||
process = self.launch_app(desktop_path, debug)
|
||||
|
||||
# Создаём лог если в debug режиме
|
||||
if debug:
|
||||
log_path = self._create_debug_log(desktop_path, process)
|
||||
if log_path:
|
||||
print(f"Debug лог создан: {log_path}")
|
||||
|
||||
return process
|
||||
|
||||
def launch_app_by_command(self, desktop_path: str,
|
||||
command: str,
|
||||
env_vars: Optional[Dict[str, str]] = None,
|
||||
debug: bool = False) -> QProcess:
|
||||
def launch_app_by_command(
|
||||
self,
|
||||
desktop_path: str,
|
||||
command: list,
|
||||
env_vars: Optional[Dict[str, str]] = None,
|
||||
debug: bool = False,
|
||||
parent: Optional[QWidget] = None,
|
||||
) -> QProcess:
|
||||
"""
|
||||
Запускает приложение по команде (для сложных случаев с env).
|
||||
|
||||
Args:
|
||||
desktop_path: Путь к .desktop файлу (для отслеживания)
|
||||
command: Команда запуска
|
||||
command: Список аргументов команды запуска
|
||||
env_vars: Дополнительные переменные окружения
|
||||
debug: Режим отладки
|
||||
parent: Родительский виджет для QProcess, чтобы deleteLater
|
||||
гарантированно удалял C++-объект
|
||||
|
||||
Returns:
|
||||
QProcess запущенного приложения
|
||||
QProcess запущенного приложения. Ошибка старта процесса ОС
|
||||
(не найден исполняемый файл и т.п.) исключение не поднимает:
|
||||
она приходит асинхронно через сигналы errorOccurred/finished
|
||||
процесса (см. _on_start_error).
|
||||
"""
|
||||
if desktop_path in self._active_processes:
|
||||
raise RuntimeError(f"Приложение уже запущено: {desktop_path}")
|
||||
|
||||
process = QProcess()
|
||||
process.setProcessChannelMode(QProcess.MergedChannels)
|
||||
if not command:
|
||||
raise ValueError("Команда запуска не может быть пустой")
|
||||
|
||||
# Настраиваем окружение
|
||||
env = QProcessEnvironment.systemEnvironment()
|
||||
if env_vars:
|
||||
for key, value in env_vars.items():
|
||||
env.insert(key, value)
|
||||
return self._start_process(
|
||||
desktop_path,
|
||||
command,
|
||||
env_vars=env_vars,
|
||||
debug=debug,
|
||||
connect_finished=False,
|
||||
parent=parent,
|
||||
)
|
||||
|
||||
def _start_process(
|
||||
self,
|
||||
desktop_path: str,
|
||||
command: list,
|
||||
env_vars: Optional[Dict[str, str]] = None,
|
||||
debug: bool = False,
|
||||
connect_finished: bool = True,
|
||||
parent: Optional[QWidget] = None,
|
||||
) -> QProcess:
|
||||
"""
|
||||
Запускает QProcess для приложения и регистрирует его.
|
||||
|
||||
Args:
|
||||
desktop_path: Путь к .desktop файлу (для отслеживания)
|
||||
command: Список аргументов команды запуска
|
||||
env_vars: Дополнительные переменные окружения
|
||||
debug: Режим отладки
|
||||
connect_finished: Подключать ли обработчик завершения
|
||||
parent: Родительский виджет для QProcess, чтобы deleteLater
|
||||
гарантированно удалял C++-объект
|
||||
|
||||
Returns:
|
||||
QProcess запущенного приложения
|
||||
"""
|
||||
process = QProcess(parent)
|
||||
|
||||
env_vars = dict(env_vars or {})
|
||||
if debug:
|
||||
env.insert("WINEDEBUG", "+loaddll,+seh")
|
||||
process.setProcessEnvironment(env)
|
||||
env_vars["WINEDEBUG"] = DEFAULT_WINEDEBUG
|
||||
ProcessUtils.prepare_process(process, env_vars)
|
||||
|
||||
# Парсим команду
|
||||
args = shlex.split(command)
|
||||
executable = args[0]
|
||||
process.start(executable, args[1:])
|
||||
|
||||
if not process.waitForStarted():
|
||||
raise RuntimeError(f"Не удалось запустить приложение: {desktop_path}")
|
||||
executable = command[0]
|
||||
logger.debug(
|
||||
"Запуск процесса: %s %s (env=%s)",
|
||||
shlex.quote(executable),
|
||||
" ".join(shlex.quote(a) for a in command[1:]),
|
||||
env_vars,
|
||||
)
|
||||
process.start(executable, command[1:])
|
||||
|
||||
# Регистрируем процесс сразу: вызывающий код получит его через
|
||||
# get_process_for_app после возврата, а неудачный старт отработает
|
||||
# _on_start_error — асинхронно, без блокировки GUI
|
||||
self._active_processes[desktop_path] = process
|
||||
# НЕ подключаем обработчик — пусть вызывает сам
|
||||
|
||||
# Ошибку старта ждём через errorOccurred, не блокируя GUI waitForStarted:
|
||||
# Qt гарантированно шлёт finished(-1, CrashExit) сразу после
|
||||
# errorOccurred(FailedToStart), поэтому UI вернётся в исходное
|
||||
# состояние обработчиками finished, подключёнными вызывающим кодом
|
||||
process.errorOccurred.connect(partial(self._on_start_error, desktop_path, process))
|
||||
|
||||
if connect_finished:
|
||||
process.finished.connect(
|
||||
lambda exit_code, exit_status: self._on_app_finished(desktop_path, process, exit_code)
|
||||
)
|
||||
|
||||
return process
|
||||
|
||||
def _on_start_error(self, desktop_path: str, process: QProcess, error) -> None:
|
||||
"""
|
||||
Обрабатывает неудачный старт процесса приложения.
|
||||
|
||||
Процесс не должен остаться в реестре запущенных: иначе кнопка
|
||||
«Остановить» и завершение сессии не смогут его завершить (зомби).
|
||||
"""
|
||||
if error != QProcess.ProcessError.FailedToStart:
|
||||
return
|
||||
logger.error("Не удалось запустить приложение %s", desktop_path)
|
||||
if self._active_processes.get(desktop_path) is process:
|
||||
self._active_processes.pop(desktop_path, None)
|
||||
try:
|
||||
# Старт мог формально провалиться после форка — добиваем процесс
|
||||
if process.state() != QProcess.ProcessState.NotRunning:
|
||||
process.kill()
|
||||
except RuntimeError:
|
||||
pass # объект QProcess уже удалён
|
||||
try:
|
||||
process.deleteLater()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def mark_app_finished(self, desktop_path: str) -> Optional[QProcess]:
|
||||
"""
|
||||
Отмечает приложение как завершённое и возвращает процесс.
|
||||
@@ -155,17 +168,6 @@ class AppLauncher:
|
||||
"""
|
||||
return self._active_processes.pop(desktop_path, None)
|
||||
|
||||
def stop_app(self, desktop_path: str) -> None:
|
||||
"""
|
||||
Останавливает запущенное приложение.
|
||||
|
||||
|
||||
Args:
|
||||
desktop_path: Путь к .desktop файлу
|
||||
"""
|
||||
process = self._active_processes.get(desktop_path)
|
||||
ProcessUtils.stop_process(process)
|
||||
|
||||
def is_app_running(self, desktop_path: str) -> bool:
|
||||
"""
|
||||
Проверяет, запущено ли приложение.
|
||||
@@ -177,7 +179,7 @@ class AppLauncher:
|
||||
True если приложение запущено
|
||||
"""
|
||||
process = self._active_processes.get(desktop_path)
|
||||
return process and process.state() == QProcess.Running
|
||||
return process is not None and process.state() == QProcess.ProcessState.Running
|
||||
|
||||
def get_running_apps(self) -> Dict[str, QProcess]:
|
||||
"""
|
||||
@@ -187,8 +189,7 @@ class AppLauncher:
|
||||
Словарь {desktop_path: QProcess}
|
||||
"""
|
||||
return {
|
||||
path: proc for path, proc in self._active_processes.items()
|
||||
if proc.state() == QProcess.Running
|
||||
path: proc for path, proc in self._active_processes.items() if proc.state() == QProcess.ProcessState.Running
|
||||
}
|
||||
|
||||
def get_process_for_app(self, desktop_path: str) -> Optional[QProcess]:
|
||||
@@ -203,91 +204,8 @@ class AppLauncher:
|
||||
"""
|
||||
return self._active_processes.get(desktop_path)
|
||||
|
||||
def connect_output_callback(self, desktop_path: str,
|
||||
callback) -> None:
|
||||
"""
|
||||
Подключает callback для обработки вывода приложения.
|
||||
|
||||
Args:
|
||||
desktop_path: Путь к .desktop файлу
|
||||
callback: Функция для обработки вывода
|
||||
"""
|
||||
process = self._active_processes.get(desktop_path)
|
||||
if process:
|
||||
def read_output():
|
||||
output = process.readAllStandardOutput().data().decode('utf-8', 'ignore')
|
||||
if output:
|
||||
callback(output)
|
||||
|
||||
process.readyReadStandardOutput.connect(read_output)
|
||||
|
||||
def stop_all(self) -> None:
|
||||
"""Останавливает все запущенные приложения."""
|
||||
for desktop_path in list(self._active_processes.keys()):
|
||||
self.stop_app(desktop_path)
|
||||
|
||||
def _on_app_finished(self, desktop_path: str, process: QProcess) -> None:
|
||||
def _on_app_finished(self, desktop_path: str, process: QProcess, exit_code: int = 0) -> None:
|
||||
"""Обработчик завершения приложения."""
|
||||
self._active_processes.pop(desktop_path, None)
|
||||
|
||||
def _get_exec_from_desktop(self, desktop_path: str) -> Optional[str]:
|
||||
"""
|
||||
Получает команду запуска из .desktop файла.
|
||||
|
||||
Args:
|
||||
desktop_path: Путь к .desktop файлу
|
||||
|
||||
Returns:
|
||||
Команда запуска или None
|
||||
"""
|
||||
try:
|
||||
with open(desktop_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line.startswith('Exec='):
|
||||
# Убираем %F, %f, %U, %u (параметры desktop-файла)
|
||||
exec_cmd = line[5:].strip()
|
||||
exec_cmd = exec_cmd.replace('%F', '').replace('%f', '')
|
||||
exec_cmd = exec_cmd.replace('%U', '').replace('%u', '')
|
||||
return exec_cmd.strip()
|
||||
except (IOError, OSError) as e:
|
||||
print(f"Ошибка чтения {desktop_path}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _create_debug_log(self, desktop_path: str, process: QProcess) -> Optional[str]:
|
||||
"""
|
||||
Создаёт debug лог для приложения.
|
||||
|
||||
Args:
|
||||
desktop_path: Путь к .desktop файлу
|
||||
process: QProcess приложения
|
||||
|
||||
Returns:
|
||||
Путь к файлу лога или None
|
||||
"""
|
||||
try:
|
||||
# Создаём директорию для логов
|
||||
log_dir = os.path.join(self.user_work_path, "logs")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
# Формируем имя файла
|
||||
app_name = os.path.splitext(os.path.basename(desktop_path))[0]
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
log_filename = f"{app_name}_{timestamp}.log"
|
||||
log_path = os.path.join(log_dir, log_filename)
|
||||
|
||||
# Читаем вывод и записываем в лог
|
||||
output = process.readAllStandardOutput().data().decode('utf-8', 'ignore')
|
||||
|
||||
with open(log_path, 'w', encoding='utf-8') as f:
|
||||
f.write(f"Debug лог для: {desktop_path}\n")
|
||||
f.write(f"Время запуска: {timestamp}\n")
|
||||
f.write("=" * 80 + "\n\n")
|
||||
f.write(output)
|
||||
|
||||
return log_path
|
||||
|
||||
except (IOError, OSError) as e:
|
||||
print(f"Ошибка создания debug лога: {e}")
|
||||
return None
|
||||
if exit_code != 0:
|
||||
logger.warning("Приложение %s завершилось с кодом %d", desktop_path, exit_code)
|
||||
@@ -1,132 +0,0 @@
|
||||
"""
|
||||
Сервис для установки приложений Wine.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
|
||||
from PyQt5.QtCore import QProcess, QProcessEnvironment
|
||||
|
||||
from winehelperqt.services.script_manager import ScriptManager, ScriptInfo
|
||||
|
||||
|
||||
class ApplicationInstaller:
|
||||
"""
|
||||
Сервис для установки приложений через Wine.
|
||||
"""
|
||||
|
||||
def __init__(self, data_path, user_work_path):
|
||||
"""
|
||||
Инициализирует установщик приложений.
|
||||
|
||||
Args:
|
||||
data_path: Путь к директории данных
|
||||
user_work_path: Путь к рабочей директории пользователя
|
||||
"""
|
||||
self.data_path = data_path
|
||||
self.user_work_path = user_work_path
|
||||
self._script_manager = ScriptManager(data_path)
|
||||
|
||||
def get_script_folders(self, folder_name):
|
||||
"""
|
||||
Возвращает список папок со скриптами.
|
||||
"""
|
||||
return self._script_manager.get_script_folders(folder_name)
|
||||
|
||||
def get_script_info(self, script_path):
|
||||
"""
|
||||
Получает информацию о скрипте установки.
|
||||
|
||||
Returns:
|
||||
Словарь с информацией о скрипте
|
||||
"""
|
||||
script_info: Optional[ScriptInfo] = self._script_manager.get_script_info(script_path)
|
||||
if script_info is None:
|
||||
return {}
|
||||
|
||||
return {
|
||||
'name': script_info.name,
|
||||
'url': script_info.url,
|
||||
'description': script_info.description,
|
||||
'icons': script_info.icons
|
||||
}
|
||||
|
||||
def list_available_apps(self, folder_name):
|
||||
"""
|
||||
Возвращает список доступных приложений.
|
||||
|
||||
Returns:
|
||||
Список словарей с информацией о приложениях
|
||||
"""
|
||||
scripts: List[ScriptInfo] = self._script_manager.list_available_apps(folder_name)
|
||||
apps = []
|
||||
|
||||
for script in scripts:
|
||||
apps.append({
|
||||
'name': script.name,
|
||||
'folder': script.folder,
|
||||
'script': script.script_path,
|
||||
'description': script.description,
|
||||
'icons': script.icons,
|
||||
'url': script.url
|
||||
})
|
||||
|
||||
return apps
|
||||
|
||||
def get_autoinstall_apps(self):
|
||||
"""Возвращает список приложений для автоустановки."""
|
||||
return self.list_available_apps(self._script_manager.AUTOINSTALL_DIR)
|
||||
|
||||
def get_manualinstall_apps(self):
|
||||
"""Возвращает список приложений для ручной установки."""
|
||||
return self.list_available_apps(self._script_manager.MANUALINSTALL_DIR)
|
||||
|
||||
def get_testinstall_apps(self):
|
||||
"""Возвращает список приложений для тестовой установки."""
|
||||
return self.list_available_apps(self._script_manager.TESTINSTALL_DIR)
|
||||
|
||||
def run_installation(self, script_path, winehelper_path, log_callback=None):
|
||||
"""
|
||||
Запускает установку приложения.
|
||||
|
||||
Args:
|
||||
script_path: Путь к скрипту установки
|
||||
winehelper_path: Путь к winehelper
|
||||
log_callback: Callback для логирования (опционально)
|
||||
|
||||
Returns:
|
||||
QProcess запущенного процесса
|
||||
"""
|
||||
process = QProcess()
|
||||
process.setProcessChannelMode(QProcess.MergedChannels)
|
||||
|
||||
env = QProcessEnvironment.systemEnvironment()
|
||||
env.insert("WINEHELPER_PATH", winehelper_path)
|
||||
process.setProcessEnvironment(env)
|
||||
|
||||
if log_callback:
|
||||
process.readyReadStandardOutput.connect(
|
||||
lambda: log_callback(process.readAllStandardOutput().data().decode('utf-8', 'ignore'))
|
||||
)
|
||||
|
||||
process.start(script_path)
|
||||
return process
|
||||
|
||||
def search_apps(self, apps, search_text):
|
||||
"""
|
||||
Ищет приложения по названию.
|
||||
|
||||
Args:
|
||||
apps: Список приложений (словари)
|
||||
search_text: Текст для поиска
|
||||
|
||||
Returns:
|
||||
Отфильтрованный список приложений
|
||||
"""
|
||||
if not search_text:
|
||||
return apps
|
||||
|
||||
search_lower = search_text.lower()
|
||||
return [
|
||||
app for app in apps
|
||||
if search_lower in app['name'].lower() or
|
||||
search_lower in app.get('description', '').lower()
|
||||
]
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Контроллер диалогов выполнения команд winehelper для WineHelper GUI.
|
||||
|
||||
Управляет модальным диалогом с логом выполнения, жизненным циклом QProcess
|
||||
и финализацией вывода (общее семейство command-диалогов: бэкап, удаление,
|
||||
установка компонентов и т.п.). Каждый запущенный диалог описывается
|
||||
собственным объектом CommandContext: обработчики завершения процесса
|
||||
получают контекст через functools.partial при подключении к process.finished,
|
||||
поэтому всегда работают со «своими» виджетами, а не с последним созданным.
|
||||
"""
|
||||
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from typing import Callable, Optional
|
||||
|
||||
from winehelperqt.dialogs.command_dialog import CommandDialog
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.qt_compat import QObject, QProcess, QPushButton, QTextEdit, QVBoxLayout
|
||||
from winehelperqt.utils.dialog_utils import DialogUtils
|
||||
from winehelperqt.utils.font_utils import fixed_font
|
||||
from winehelperqt.utils.process_utils import ProcessUtils
|
||||
|
||||
logger = get_logger(__name__)
|
||||
# Отдельный логгер для вывода внешних процессов (bash, wine),
|
||||
# чтобы в консоли отличать его от событий самого GUI
|
||||
process_logger = get_logger("process")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandContext:
|
||||
"""Состояние одного диалога выполнения команды: виджеты и процесс."""
|
||||
|
||||
dialog: CommandDialog
|
||||
process: QProcess
|
||||
log_output: QTextEdit
|
||||
close_button: QPushButton
|
||||
|
||||
|
||||
class CommandRunner(QObject):
|
||||
"""Создаёт и обслуживает диалоги выполнения команд winehelper."""
|
||||
|
||||
def __init__(self, parent, log_handler):
|
||||
"""
|
||||
Инициализирует контроллер команд.
|
||||
|
||||
Args:
|
||||
parent: Родительский виджет (главное окно)
|
||||
log_handler: MultiLogHandler для обработки логов команд
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._log_handler = log_handler
|
||||
# Последний созданный контекст: нужен terminate_process() для завершения
|
||||
# активной команды при выходе из приложения
|
||||
self._last_context: Optional[CommandContext] = None
|
||||
|
||||
def create_dialog(
|
||||
self,
|
||||
title,
|
||||
command,
|
||||
args,
|
||||
env_vars=None,
|
||||
finished_callback=None,
|
||||
min_width=780,
|
||||
min_height=400,
|
||||
output_handler=None,
|
||||
initial_text=None,
|
||||
) -> CommandContext:
|
||||
"""
|
||||
Создает стандартный модальный диалог для выполнения команд winehelper.
|
||||
|
||||
Args:
|
||||
title: Заголовок диалога
|
||||
command: Путь к исполняемому файлу
|
||||
args: Список аргументов команды
|
||||
env_vars: Словарь дополнительных переменных окружения (опционально)
|
||||
finished_callback: Функция обратного вызова при завершении.
|
||||
Вызывается как finished_callback(context, exit_code, exit_status),
|
||||
где context — CommandContext этого диалога. Если не задана,
|
||||
используется стандартный handle_finished.
|
||||
min_width: Минимальная ширина диалога
|
||||
min_height: Минимальная высота диалога
|
||||
output_handler: Обработчик вывода процесса (опционально, по умолчанию append_output)
|
||||
initial_text: Текст, записываемый в лог до запуска процесса (опционально)
|
||||
|
||||
Returns:
|
||||
CommandContext созданного диалога (диалог — поле context.dialog)
|
||||
"""
|
||||
dialog = CommandDialog(self.parent())
|
||||
dialog.setWindowTitle(title)
|
||||
dialog.setMinimumSize(min_width, min_height)
|
||||
dialog.setModal(True)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
log_output = QTextEdit()
|
||||
log_output.setReadOnly(True)
|
||||
log_output.setFont(fixed_font(10))
|
||||
layout.addWidget(log_output)
|
||||
|
||||
# Сбрасываем состояние общего обработчика логов "command": предыдущий
|
||||
# диалог мог закончиться на строке прогресса, и её логика стёрла бы
|
||||
# первую строку вывода этого диалога
|
||||
self._log_handler.get_handler("command", log_output).reset_state()
|
||||
|
||||
close_button = QPushButton("Закрыть")
|
||||
close_button.setEnabled(False)
|
||||
close_button.clicked.connect(dialog.close)
|
||||
layout.addWidget(close_button)
|
||||
dialog.setLayout(layout)
|
||||
|
||||
process = QProcess(dialog)
|
||||
dialog.set_process(process)
|
||||
process.destroyed.connect(lambda _=None, dlg=dialog: dlg.set_process(None))
|
||||
if output_handler:
|
||||
process.readyReadStandardOutput.connect(output_handler)
|
||||
else:
|
||||
process.readyReadStandardOutput.connect(lambda p=process, out=log_output: self.append_output(p, out))
|
||||
|
||||
context = CommandContext(dialog=dialog, process=process, log_output=log_output, close_button=close_button)
|
||||
|
||||
# Обработчики завершения подключаются ДО exec() и получают контекст
|
||||
# через partial: завершившийся процесс обрабатывается со своими виджетами,
|
||||
# даже если синглтон уже создал другой диалог
|
||||
if finished_callback:
|
||||
process.finished.connect(partial(self._run_finished_callback, finished_callback, context))
|
||||
else:
|
||||
process.finished.connect(partial(self.handle_finished, context))
|
||||
|
||||
ProcessUtils.prepare_process(process, env_vars)
|
||||
|
||||
command_line = f"Выполнение: {shlex.quote(command)} {' '.join(shlex.quote(a) for a in args)}"
|
||||
log_output.append(f"{command_line}\n")
|
||||
# Зеркалируем в консоль: всё, что видно в диалоге, должно быть и в консоли
|
||||
process_logger.info("%s", command_line)
|
||||
logger.debug(
|
||||
"Запуск команды: %s %s",
|
||||
shlex.quote(command),
|
||||
" ".join(shlex.quote(a) for a in args),
|
||||
)
|
||||
if initial_text:
|
||||
log_output.append(initial_text)
|
||||
for line in initial_text.splitlines():
|
||||
if line.strip():
|
||||
process_logger.info("%s", line)
|
||||
process.start(command, args)
|
||||
|
||||
self._last_context = context
|
||||
|
||||
return context
|
||||
|
||||
@staticmethod
|
||||
def _run_finished_callback(callback: Callable, context: CommandContext, exit_code: int, exit_status) -> None:
|
||||
"""Вызывает пользовательский колбэк завершения, передавая ему контекст диалога."""
|
||||
callback(context, exit_code, exit_status)
|
||||
|
||||
def append_output(self, process, log_output):
|
||||
"""
|
||||
Добавляет вывод процесса в лог диалога, дублируя его в консоль.
|
||||
|
||||
Гонит вывод через LogProgressHandler: единая воронка для всех команд,
|
||||
корректный однострочный прогресс и чистые строки в консоли (без сырых \r).
|
||||
"""
|
||||
if DialogUtils.is_widget_deleted(log_output):
|
||||
return
|
||||
output = process.readAllStandardOutput().data().decode("utf-8", errors="ignore")
|
||||
if output:
|
||||
handler = self._log_handler.get_handler("command", log_output)
|
||||
handler.process_output(output)
|
||||
|
||||
def handle_finished(
|
||||
self,
|
||||
context: CommandContext,
|
||||
exit_code: int,
|
||||
exit_status,
|
||||
success_message: Optional[str] = None,
|
||||
on_success_callback: Optional[Callable] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Обрабатывает завершение для диалога команды.
|
||||
|
||||
Args:
|
||||
context: Контекст завершившегося диалога (его виджеты и процесс)
|
||||
exit_code: Код завершения процесса
|
||||
exit_status: Статус завершения
|
||||
success_message: Сообщение об успехе (по умолчанию "Команда успешно завершена")
|
||||
on_success_callback: Callback функция, вызываемая при успехе
|
||||
"""
|
||||
process = context.process
|
||||
log_output = context.log_output
|
||||
close_button = context.close_button
|
||||
|
||||
# Финализируем обработчик логов
|
||||
if not DialogUtils.is_widget_deleted(log_output):
|
||||
handler = self._log_handler.get_handler("command", log_output)
|
||||
handler.finalize()
|
||||
|
||||
if not DialogUtils.is_widget_deleted(log_output):
|
||||
try:
|
||||
if exit_code == 0:
|
||||
message = success_message if success_message else "\n=== Команда успешно завершена ==="
|
||||
log_output.append(message)
|
||||
for line in message.splitlines():
|
||||
if line.strip():
|
||||
process_logger.info("%s", line)
|
||||
if on_success_callback:
|
||||
on_success_callback()
|
||||
elif getattr(context.dialog, "aborted_by_user", False):
|
||||
# Прерывание по решению пользователя — не ошибка
|
||||
message = "\n=== Выполнение прервано пользователем ==="
|
||||
log_output.append(message)
|
||||
process_logger.info("%s", message.strip("\n"))
|
||||
logger.info("Команда прервана пользователем (код: %d)", exit_code)
|
||||
else:
|
||||
error_message = f"\n=== Ошибка выполнения (код: {exit_code}) ==="
|
||||
log_output.append(error_message)
|
||||
process_logger.error("%s", error_message.strip("\n"))
|
||||
logger.error("Команда завершилась с кодом %d (статус: %s)", exit_code, exit_status)
|
||||
log_output.ensureCursorVisible()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
if not DialogUtils.is_widget_deleted(process):
|
||||
try:
|
||||
process.deleteLater()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
if not DialogUtils.is_widget_deleted(close_button):
|
||||
try:
|
||||
close_button.setEnabled(True)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def terminate_process(self):
|
||||
"""Завершает активный процесс команды, если он запущен (без блокировки UI)."""
|
||||
context = self._last_context
|
||||
if context and not DialogUtils.is_widget_deleted(context.process):
|
||||
ProcessUtils.stop_process(context.process)
|
||||
@@ -1,9 +1,14 @@
|
||||
"""
|
||||
Сервис для управления .desktop файлами.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import shlex
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.utils.desktop_entry import DesktopEntry
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DesktopFileManager:
|
||||
@@ -51,11 +56,12 @@ class DesktopFileManager:
|
||||
return []
|
||||
|
||||
try:
|
||||
return [
|
||||
f.replace('.desktop', '') for f in os.listdir(self.desktop_dir)
|
||||
if f.endswith('.desktop')
|
||||
]
|
||||
except OSError:
|
||||
return sorted(
|
||||
(f.replace(".desktop", "") for f in os.listdir(self.desktop_dir) if f.endswith(".desktop")),
|
||||
key=str.lower,
|
||||
)
|
||||
except OSError as e:
|
||||
logger.warning("Не удалось прочитать директорию .desktop файлов %s: %s", self.desktop_dir, e)
|
||||
return []
|
||||
|
||||
def read_desktop_file(self, app_name):
|
||||
@@ -73,87 +79,24 @@ class DesktopFileManager:
|
||||
if not os.path.exists(desktop_path):
|
||||
return {}
|
||||
|
||||
result = {}
|
||||
try:
|
||||
with open(desktop_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#') or line.startswith('['):
|
||||
continue
|
||||
|
||||
if '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
result[key.strip()] = value.strip()
|
||||
except (IOError, OSError):
|
||||
entry = DesktopEntry(desktop_path)
|
||||
entry.load()
|
||||
data = dict(entry.section("Desktop Entry"))
|
||||
logger.debug("Прочитан .desktop файл %s", desktop_path)
|
||||
return data
|
||||
except (IOError, OSError) as e:
|
||||
logger.warning("Не удалось прочитать .desktop файл %s: %s", desktop_path, e)
|
||||
return {}
|
||||
|
||||
return result
|
||||
|
||||
def get_app_name_from_desktop(self, app_name):
|
||||
"""
|
||||
Получает имя приложения из .desktop файла.
|
||||
|
||||
Args:
|
||||
app_name: Имя приложения
|
||||
|
||||
Returns:
|
||||
Отображаемое имя приложения
|
||||
"""
|
||||
data = self.read_desktop_file(app_name)
|
||||
return data.get('Name', app_name)
|
||||
|
||||
def get_app_icon_from_desktop(self, app_name):
|
||||
"""
|
||||
Получает путь к иконке приложения.
|
||||
|
||||
Args:
|
||||
app_name: Имя приложения
|
||||
|
||||
Returns:
|
||||
Путь к иконке или None
|
||||
"""
|
||||
data = self.read_desktop_file(app_name)
|
||||
return data.get('Icon')
|
||||
|
||||
def delete_desktop_file(self, app_name):
|
||||
"""
|
||||
Удаляет .desktop файл приложения.
|
||||
|
||||
Args:
|
||||
app_name: Имя приложения
|
||||
|
||||
Returns:
|
||||
True если удаление успешно, False иначе
|
||||
"""
|
||||
desktop_path = self.get_desktop_file_path(app_name)
|
||||
|
||||
if not os.path.exists(desktop_path):
|
||||
return False, f"Файл '{desktop_path}' не найден"
|
||||
|
||||
try:
|
||||
os.remove(desktop_path)
|
||||
return True, "Файл удален"
|
||||
except OSError as e:
|
||||
return False, f"Ошибка удаления: {e}"
|
||||
|
||||
def desktop_file_exists(self, app_name):
|
||||
"""
|
||||
Проверяет существование .desktop файла.
|
||||
|
||||
Args:
|
||||
app_name: Имя приложения
|
||||
|
||||
Returns:
|
||||
True если файл существует, False иначе
|
||||
"""
|
||||
return os.path.exists(self.get_desktop_file_path(app_name))
|
||||
|
||||
def _get_exe_path_from_exec(self, exec_line):
|
||||
@staticmethod
|
||||
def get_exe_path_from_exec(exec_line, base_dir=None):
|
||||
"""
|
||||
Извлекает путь к .exe файлу из строки Exec.
|
||||
|
||||
Args:
|
||||
exec_line: Строка Exec из .desktop файла
|
||||
base_dir: Базовая директория для разрешения относительных путей
|
||||
|
||||
Returns:
|
||||
Путь к .exe файлу или None
|
||||
@@ -161,27 +104,26 @@ class DesktopFileManager:
|
||||
if not exec_line:
|
||||
return None
|
||||
|
||||
exec_line = exec_line.split('%')[0].strip()
|
||||
exec_line = exec_line.split("%")[0].strip()
|
||||
|
||||
quoted_match = re.search(r'"([^"]*\.[eE][xX][eE])"', exec_line)
|
||||
if quoted_match:
|
||||
exe_path = quoted_match.group(1)
|
||||
if os.path.exists(exe_path):
|
||||
return exe_path
|
||||
try:
|
||||
parts = shlex.split(exec_line)
|
||||
except ValueError as e:
|
||||
logger.debug("Не удалось распарсить строку Exec (%s): %s", exec_line, e)
|
||||
parts = exec_line.split()
|
||||
|
||||
parts = exec_line.split()
|
||||
for part in parts:
|
||||
part_clean = part.strip('"\'')
|
||||
if part_clean.lower().endswith('.exe'):
|
||||
if os.path.exists(part_clean):
|
||||
return part_clean
|
||||
if not part_clean.startswith('/'):
|
||||
continue
|
||||
part_clean = part.strip("\"'")
|
||||
if not part_clean.lower().endswith(".exe"):
|
||||
continue
|
||||
|
||||
if parts:
|
||||
last_part = parts[-1].strip('"\'')
|
||||
if last_part.lower().endswith('.exe') and os.path.exists(last_part):
|
||||
return last_part
|
||||
if os.path.exists(part_clean):
|
||||
return part_clean
|
||||
|
||||
if base_dir and not os.path.isabs(part_clean):
|
||||
relative_path = os.path.join(base_dir, part_clean)
|
||||
if os.path.exists(relative_path):
|
||||
return relative_path
|
||||
|
||||
return None
|
||||
|
||||
@@ -196,38 +138,19 @@ class DesktopFileManager:
|
||||
apps = []
|
||||
for app_name in self.list_desktop_files():
|
||||
data = self.read_desktop_file(app_name)
|
||||
exec_line = data.get('Exec', '')
|
||||
exe_path = self._get_exe_path_from_exec(exec_line)
|
||||
exec_line = data.get("Exec", "")
|
||||
exe_path = self.get_exe_path_from_exec(exec_line)
|
||||
if exe_path is None:
|
||||
continue
|
||||
apps.append({
|
||||
'name': app_name,
|
||||
'display_name': data.get('Name', app_name),
|
||||
'icon': data.get('Icon'),
|
||||
'exec': exec_line,
|
||||
'path': self.get_desktop_file_path(app_name)
|
||||
})
|
||||
apps.append(
|
||||
{
|
||||
"name": app_name,
|
||||
"display_name": data.get("Name", app_name),
|
||||
"icon": data.get("Icon"),
|
||||
"exec": exec_line,
|
||||
"path": self.get_desktop_file_path(app_name),
|
||||
}
|
||||
)
|
||||
# Сортируем по отображаемому имени, чтобы порядок кнопок был стабильным
|
||||
apps.sort(key=lambda app: app["display_name"].lower())
|
||||
return apps
|
||||
|
||||
def copy_desktop_file(self, source_path, dest_name):
|
||||
"""
|
||||
Копирует .desktop файл в директорию пользователя.
|
||||
|
||||
Args:
|
||||
source_path: Путь к исходному файлу
|
||||
dest_name: Имя destination файла (без расширения)
|
||||
|
||||
Returns:
|
||||
True если копирование успешно, False иначе
|
||||
"""
|
||||
if not os.path.exists(source_path):
|
||||
return False, f"Исходный файл не найден: {source_path}"
|
||||
|
||||
os.makedirs(self.desktop_dir, exist_ok=True)
|
||||
dest_path = self.get_desktop_file_path(dest_name)
|
||||
|
||||
try:
|
||||
shutil.copy2(source_path, dest_path)
|
||||
return True, "Файл скопирован"
|
||||
except (IOError, OSError) as e:
|
||||
return False, f"Ошибка копирования: {e}"
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Сервис для управления глобальными настройками WineHelper GUI.
|
||||
|
||||
Хранит настройки в ~/.config/winehelper/logging.conf
|
||||
(или в директории, заданной XDG_CONFIG_HOME).
|
||||
"""
|
||||
|
||||
import configparser
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GlobalSettingsService:
|
||||
"""Сервис для чтения/записи глобальных настроек приложения."""
|
||||
|
||||
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "winehelper"
|
||||
CONFIG_PATH = CONFIG_DIR / "logging.conf"
|
||||
|
||||
DEFAULT_LOG_LEVEL = "info"
|
||||
VALID_LOG_LEVELS = ("debug", "info", "warning", "error")
|
||||
|
||||
DEFAULT_QT_BINDING = "auto"
|
||||
VALID_QT_BINDINGS = ("auto", "pyqt6", "pyqt5")
|
||||
|
||||
def __init__(self, config_path=None):
|
||||
"""
|
||||
Инициализирует сервис настроек.
|
||||
|
||||
Args:
|
||||
config_path: Путь к файлу конфигурации (опционально).
|
||||
"""
|
||||
self.config_path = Path(config_path) if config_path else self.CONFIG_PATH
|
||||
self._ensure_config_dir()
|
||||
|
||||
def _ensure_config_dir(self):
|
||||
"""Создаёт директорию конфигурации, если она отсутствует."""
|
||||
try:
|
||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as e:
|
||||
logger.warning("Не удалось создать директорию конфигурации %s: %s", self.config_path.parent, e)
|
||||
|
||||
def _load_config(self):
|
||||
"""Загружает конфигурацию из файла."""
|
||||
config = configparser.ConfigParser()
|
||||
if self.config_path.exists():
|
||||
try:
|
||||
config.read(self.config_path, encoding="utf-8")
|
||||
except (configparser.Error, OSError) as e:
|
||||
logger.warning("Не удалось прочитать конфиг %s: %s", self.config_path, e)
|
||||
if "gui" not in config:
|
||||
config["gui"] = {}
|
||||
return config
|
||||
|
||||
def _save_config(self, config):
|
||||
"""Сохраняет конфигурацию в файл."""
|
||||
try:
|
||||
self._ensure_config_dir()
|
||||
with open(self.config_path, "w", encoding="utf-8") as f:
|
||||
config.write(f)
|
||||
except OSError as e:
|
||||
logger.error("Не удалось сохранить конфиг %s: %s", self.config_path, e)
|
||||
|
||||
def get_log_level(self):
|
||||
"""
|
||||
Возвращает уровень логирования из конфигурации.
|
||||
|
||||
Returns:
|
||||
str: один из debug/info/warning/error.
|
||||
"""
|
||||
config = self._load_config()
|
||||
level = config["gui"].get("log_level", self.DEFAULT_LOG_LEVEL).lower().strip()
|
||||
if level not in self.VALID_LOG_LEVELS:
|
||||
logger.warning("Неизвестный уровень логирования '%s', используется '%s'", level, self.DEFAULT_LOG_LEVEL)
|
||||
return self.DEFAULT_LOG_LEVEL
|
||||
return level
|
||||
|
||||
def set_log_level(self, level):
|
||||
"""
|
||||
Сохраняет уровень логирования в конфигурации.
|
||||
|
||||
Args:
|
||||
level: debug/info/warning/error.
|
||||
|
||||
Returns:
|
||||
bool: True если значение корректно и сохранено.
|
||||
"""
|
||||
level = level.lower().strip()
|
||||
if level not in self.VALID_LOG_LEVELS:
|
||||
logger.error("Попытка сохранить некорректный уровень логирования: %s", level)
|
||||
return False
|
||||
|
||||
config = self._load_config()
|
||||
config["gui"]["log_level"] = level
|
||||
self._save_config(config)
|
||||
logger.debug("Уровень логирования сохранён: %s", level)
|
||||
return True
|
||||
|
||||
def get_qt_binding(self):
|
||||
"""
|
||||
Возвращает выбранный Qt binding из конфигурации.
|
||||
|
||||
Returns:
|
||||
str: один из auto/pyqt6/pyqt5.
|
||||
"""
|
||||
config = self._load_config()
|
||||
binding = config["gui"].get("qt_binding", self.DEFAULT_QT_BINDING).lower().strip()
|
||||
if binding not in self.VALID_QT_BINDINGS:
|
||||
logger.warning("Неизвестный Qt binding '%s', используется '%s'", binding, self.DEFAULT_QT_BINDING)
|
||||
return self.DEFAULT_QT_BINDING
|
||||
return binding
|
||||
|
||||
def set_qt_binding(self, binding):
|
||||
"""
|
||||
Сохраняет выбранный Qt binding в конфигурации.
|
||||
|
||||
Применяется при следующем запуске GUI: winehelperqt/qt_compat.py
|
||||
читает этот ключ при импорте (переменная WH_QT_BINDING имеет приоритет).
|
||||
|
||||
Args:
|
||||
binding: auto/pyqt6/pyqt5.
|
||||
|
||||
Returns:
|
||||
bool: True если значение корректно и сохранено.
|
||||
"""
|
||||
binding = binding.lower().strip()
|
||||
if binding not in self.VALID_QT_BINDINGS:
|
||||
logger.error("Попытка сохранить некорректный Qt binding: %s", binding)
|
||||
return False
|
||||
|
||||
config = self._load_config()
|
||||
config["gui"]["qt_binding"] = binding
|
||||
self._save_config(config)
|
||||
logger.debug("Qt binding сохранён: %s", binding)
|
||||
return True
|
||||
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
Контроллер процесса установки приложений для WineHelper GUI.
|
||||
|
||||
Управляет диалогом установки, жизненным циклом QProcess и логом установки.
|
||||
Главное окно получает результат через сигнал finished.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
|
||||
from winehelperqt.dialogs.install_dialog import InstallDialog
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.qt_compat import (
|
||||
QHBoxLayout,
|
||||
QMessageBox,
|
||||
QObject,
|
||||
QProcess,
|
||||
QPushButton,
|
||||
Qt,
|
||||
QTextCursor,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
pyqtSignal,
|
||||
)
|
||||
from winehelperqt.utils.dialog_utils import DialogUtils
|
||||
from winehelperqt.utils.font_utils import fixed_font
|
||||
from winehelperqt.utils.log_handler import process_logger
|
||||
from winehelperqt.utils.process_utils import ProcessUtils
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class InstallRunner(QObject):
|
||||
"""Управляет диалогом и процессом установки приложения."""
|
||||
|
||||
# exit_code, exit_status, cancelled — завершение процесса установки
|
||||
finished = pyqtSignal(int, QProcess.ExitStatus, bool)
|
||||
|
||||
def __init__(self, parent, log_handler):
|
||||
"""
|
||||
Инициализирует контроллер установки.
|
||||
|
||||
Args:
|
||||
parent: Родительский виджет (главное окно)
|
||||
log_handler: MultiLogHandler для обработки логов установки
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._log_handler = log_handler
|
||||
self.dialog = None
|
||||
self.log_output = None
|
||||
self.process = None
|
||||
self.title_name = ""
|
||||
self.cancelled = False
|
||||
self._start_failed = False
|
||||
self._btn_abort = None
|
||||
self._btn_close = None
|
||||
|
||||
def create_dialog(self, title_name):
|
||||
"""Создает и показывает диалог установки с логом и кнопками управления."""
|
||||
self.title_name = title_name
|
||||
self.cancelled = False
|
||||
self._start_failed = False
|
||||
|
||||
# Создаем диалоговое окно установки
|
||||
self.dialog = InstallDialog(self.parent())
|
||||
self.dialog.setWindowTitle(f"Установка «{title_name}»")
|
||||
self.dialog.setMinimumSize(780, 400)
|
||||
self.dialog.setWindowModality(Qt.WindowModality.WindowModal)
|
||||
self.dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) # Удалять диалог при закрытии
|
||||
self.dialog.set_abort_callback(self._abort_on_dialog_close)
|
||||
log_layout = QVBoxLayout(self.dialog)
|
||||
|
||||
self.log_output = QTextEdit()
|
||||
self.log_output.setReadOnly(True)
|
||||
self.log_output.setFont(fixed_font(10))
|
||||
log_layout.addWidget(self.log_output)
|
||||
|
||||
control_buttons = QWidget()
|
||||
btn_layout = QHBoxLayout(control_buttons)
|
||||
self._btn_abort = QPushButton("Прервать")
|
||||
self._btn_abort.clicked.connect(self.abort_installation)
|
||||
btn_layout.addWidget(self._btn_abort)
|
||||
|
||||
self._btn_close = QPushButton("Закрыть")
|
||||
self._btn_close.setEnabled(False)
|
||||
self._btn_close.clicked.connect(self.dialog.close)
|
||||
btn_layout.addWidget(self._btn_close)
|
||||
log_layout.addWidget(control_buttons)
|
||||
|
||||
self.dialog.show()
|
||||
|
||||
def reset_log_state(self):
|
||||
"""Сбрасывает состояние обработчика логов установки."""
|
||||
handler = self._log_handler.get_handler_or_none("install")
|
||||
if handler:
|
||||
handler.reset_state()
|
||||
|
||||
def start(self, winehelper_path, script_path, install_file=None, external=False, clear_pfx=False):
|
||||
"""Запускает процесс установки."""
|
||||
# Устанавливаем родителя для QProcess, чтобы он корректно удалялся вместе с диалогом
|
||||
self.process = QProcess(self.dialog)
|
||||
self.dialog.set_process(self.process)
|
||||
self.process.setWorkingDirectory(os.path.dirname(winehelper_path))
|
||||
|
||||
ProcessUtils.prepare_process(self.process)
|
||||
|
||||
self.process.readyReadStandardOutput.connect(self._handle_output)
|
||||
self.process.finished.connect(self._handle_finished)
|
||||
|
||||
# Для сторонних скриптов передаем полный путь, для штатных — только имя
|
||||
args = ["install"]
|
||||
if clear_pfx:
|
||||
args.append("--clear-pfx")
|
||||
args.append(script_path if external else os.path.basename(script_path))
|
||||
if install_file:
|
||||
args.append(install_file)
|
||||
|
||||
self.append_log(f"=== Начало установки «{self.title_name}» ===")
|
||||
self.append_log(f"Исполняемый файл: {winehelper_path}")
|
||||
self.append_log(f"Аргументы: {' '.join(shlex.quote(a) for a in args)}")
|
||||
|
||||
logger.debug(
|
||||
"Запуск установки: %s %s",
|
||||
shlex.quote(winehelper_path),
|
||||
" ".join(shlex.quote(a) for a in args),
|
||||
)
|
||||
|
||||
# Ошибку старта ждём асинхронно через errorOccurred, не блокируя GUI
|
||||
# waitForStarted: Qt шлёт finished(-1, CrashExit) сразу после
|
||||
# errorOccurred(FailedToStart), поэтому диалог доработает штатно
|
||||
self.process.errorOccurred.connect(self._handle_start_error)
|
||||
self.process.start(winehelper_path, args)
|
||||
self.append_log("Процесс установки запущен...")
|
||||
|
||||
def _handle_start_error(self, error):
|
||||
"""Обрабатывает ошибку старта процесса установки (в т.ч. FailedToStart)."""
|
||||
if error != QProcess.ProcessError.FailedToStart:
|
||||
return
|
||||
# Помечаем неудачу: следом Qt шлёт finished(-1, CrashExit), и _handle_finished
|
||||
# не должен добавить второе сообщение об «аварийном завершении»
|
||||
self._start_failed = True
|
||||
self.append_log("\n=== ОШИБКА: не удалось запустить процесс установки ===", is_error=True)
|
||||
QMessageBox.critical(self.dialog, "Ошибка", "Не удалось запустить процесс установки.")
|
||||
self.cleanup_process()
|
||||
|
||||
def abort_installation(self):
|
||||
"""Прерывает текущую установку."""
|
||||
if self.process is None or self.process.state() != QProcess.ProcessState.Running:
|
||||
return
|
||||
|
||||
if not DialogUtils.confirm(self.dialog, "Подтверждение", "Вы действительно хотите прервать установку?"):
|
||||
return
|
||||
|
||||
self.append_log("\n=== Пользователь прервал установку ===", is_error=True)
|
||||
self.cancelled = True
|
||||
self.process.terminate()
|
||||
|
||||
def cleanup_process(self):
|
||||
"""Очищает ресурсы процесса, принудительно завершая его, если он активен."""
|
||||
if self.process:
|
||||
ProcessUtils.stop_process(self.process)
|
||||
self.process.deleteLater()
|
||||
self.process = None
|
||||
|
||||
def append_log(self, text, is_error=False, add_newline=True, is_success=False):
|
||||
"""Добавляет сообщение в лог установки и дублирует в консоль."""
|
||||
if self.log_output:
|
||||
try:
|
||||
self._append_to_log(self.log_output, text, is_error, add_newline, is_success)
|
||||
except RuntimeError:
|
||||
pass # Виджет был удалён (диалог закрыт)
|
||||
|
||||
# Зеркалим в консоль через тот же логгер, что и вывод процесса,
|
||||
# чтобы содержимое диалога было единым потоком; ведущие/хвостовые
|
||||
# переносы не логируем, иначе префикс строки уйдёт на отдельную строку
|
||||
if not text:
|
||||
return
|
||||
if is_error:
|
||||
process_logger.error("%s", text.strip("\n"))
|
||||
else:
|
||||
process_logger.info("%s", text.strip("\n"))
|
||||
|
||||
def _handle_output(self):
|
||||
"""Обрабатывает вывод процесса, корректно отображая однострочный прогресс."""
|
||||
new_data = self.process.readAllStandardOutput().data().decode("utf-8", errors="ignore")
|
||||
handler = self._log_handler.get_handler("install", self.log_output)
|
||||
handler.process_output(new_data)
|
||||
|
||||
def _handle_finished(self, exit_code, exit_status):
|
||||
"""Обрабатывает завершение процесса установки."""
|
||||
handler = self._log_handler.get_handler("install", self.log_output)
|
||||
handler.finalize()
|
||||
|
||||
if self.cancelled:
|
||||
self.append_log("\n=== Установка была прервана. ===", is_error=True)
|
||||
self.cleanup_process()
|
||||
elif self._start_failed:
|
||||
# Сообщение об ошибке старта уже записано в _handle_start_error
|
||||
pass
|
||||
elif exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
|
||||
self.append_log("\n=== Установка завершена успешно ===", is_success=True)
|
||||
else:
|
||||
# Ошибка установки: сообщаем явно, чтобы пользователь не искал причину в логе сам
|
||||
if exit_status == QProcess.ExitStatus.CrashExit:
|
||||
self.append_log("\n=== Установка завершилась с ошибкой: процесс аварийно завершён ===", is_error=True)
|
||||
else:
|
||||
self.append_log(f"\n=== Установка завершилась с ошибкой (код выхода: {exit_code}) ===", is_error=True)
|
||||
|
||||
# Кнопка закрыть
|
||||
self._btn_close.setEnabled(True)
|
||||
# Кнопка прервать
|
||||
self._btn_abort.setEnabled(False)
|
||||
self.process = None
|
||||
self.finished.emit(exit_code, exit_status, self.cancelled)
|
||||
|
||||
def _abort_on_dialog_close(self):
|
||||
"""Прерывает установку при подтверждённом закрытии окна во время процесса."""
|
||||
self.append_log("\n=== Пользователь прервал установку через закрытие окна. Ожидание... ===", is_error=True)
|
||||
self.cancelled = True
|
||||
self.process.terminate()
|
||||
|
||||
def _append_to_log(self, log_widget, text, is_error=False, add_newline=True, is_success=False):
|
||||
"""Helper to append text to a QTextEdit log widget."""
|
||||
if not log_widget or DialogUtils.is_widget_deleted(log_widget):
|
||||
return
|
||||
try:
|
||||
cursor = log_widget.textCursor()
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
|
||||
if is_error or is_success:
|
||||
# В HTML-вставке переводы строк схлопываются, заменяем их на <br>
|
||||
color = "red" if is_error else "green"
|
||||
html_text = text.replace("\n", "<br>")
|
||||
cursor.insertHtml(f'<font color="{color}">{html_text}</font><br>')
|
||||
else:
|
||||
formatted_text = f"{text}\n" if add_newline else text
|
||||
cursor.insertText(formatted_text)
|
||||
|
||||
log_widget.ensureCursorVisible()
|
||||
except RuntimeError:
|
||||
pass
|
||||
@@ -1,180 +0,0 @@
|
||||
"""
|
||||
Сервис для управления процессом установки приложений WineHelper.
|
||||
|
||||
Предоставляет функциональность:
|
||||
- Запуск процесса установки
|
||||
- Мониторинг прогресса установки
|
||||
- Обработка логов (stdout/stderr)
|
||||
- Управление процессами (остановка, завершение)
|
||||
- Отмена установки
|
||||
"""
|
||||
import os
|
||||
from typing import Dict, Optional, Callable
|
||||
|
||||
from PyQt5.QtCore import QProcess, QProcessEnvironment
|
||||
|
||||
from winehelperqt.utils.process_utils import ProcessUtils
|
||||
|
||||
|
||||
class InstallationManager:
|
||||
"""Сервис для управления процессом установки приложений."""
|
||||
|
||||
def __init__(self, winehelper_path: str):
|
||||
"""
|
||||
Инициализирует менеджер установки.
|
||||
|
||||
Args:
|
||||
winehelper_path: Путь к скрипту winehelper
|
||||
"""
|
||||
self.winehelper_path = winehelper_path
|
||||
self._active_processes: Dict[str, QProcess] = {}
|
||||
|
||||
def start_install(self, script_path: str,
|
||||
work_path: Optional[str] = None,
|
||||
env_vars: Optional[Dict[str, str]] = None) -> QProcess:
|
||||
"""
|
||||
Запускает процесс установки приложения.
|
||||
|
||||
Args:
|
||||
script_path: Путь к скрипту установки (install.sh)
|
||||
work_path: Рабочая директория (по умолчанию директория скрипта)
|
||||
env_vars: Дополнительные переменные окружения
|
||||
|
||||
Returns:
|
||||
QProcess запущенного процесса
|
||||
"""
|
||||
if not os.path.exists(script_path):
|
||||
raise FileNotFoundError(f"Скрипт установки не найден: {script_path}")
|
||||
|
||||
process = QProcess()
|
||||
process.setProcessChannelMode(QProcess.MergedChannels)
|
||||
|
||||
# Настраиваем окружение
|
||||
env = QProcessEnvironment.systemEnvironment()
|
||||
|
||||
# Добавляем пользовательские переменные окружения
|
||||
if env_vars:
|
||||
for key, value in env_vars.items():
|
||||
env.insert(key, value)
|
||||
|
||||
process.setProcessEnvironment(env)
|
||||
|
||||
# Устанавливаем рабочую директорию
|
||||
if work_path:
|
||||
process.setWorkingDirectory(work_path)
|
||||
|
||||
# Запускаем скрипт
|
||||
process.start(script_path)
|
||||
|
||||
if not process.waitForStarted():
|
||||
raise RuntimeError(f"Не удалось запустить процесс установки: {script_path}")
|
||||
|
||||
return process
|
||||
|
||||
def start_manual_install(self, script_path: str,
|
||||
installer_file: str,
|
||||
env_vars: Optional[Dict[str, str]] = None) -> QProcess:
|
||||
"""
|
||||
Запускает ручную установку с указанием файла установщика.
|
||||
|
||||
Args:
|
||||
script_path: Путь к скрипту установки
|
||||
installer_file: Путь к файлу установщика (.exe или .msi)
|
||||
env_vars: Дополнительные переменные окружения
|
||||
|
||||
Returns:
|
||||
QProcess запущенного процесса
|
||||
"""
|
||||
# Для ручной установки передаём путь к файлу через окружение
|
||||
custom_env = env_vars.copy() if env_vars else {}
|
||||
custom_env['USER_INSTALL_FILE'] = installer_file
|
||||
|
||||
return self.start_install(script_path, env_vars=custom_env)
|
||||
|
||||
def stop_install(self, process: QProcess) -> None:
|
||||
"""
|
||||
Останавливает процесс установки.
|
||||
|
||||
|
||||
Args:
|
||||
process: Процесс установки для остановки
|
||||
"""
|
||||
ProcessUtils.stop_process(process)
|
||||
|
||||
def is_running(self, process: QProcess) -> bool:
|
||||
"""
|
||||
Проверяет, запущен ли процесс установки.
|
||||
|
||||
Args:
|
||||
process: Процесс для проверки
|
||||
|
||||
Returns:
|
||||
True если процесс запущен
|
||||
"""
|
||||
return process and process.state() == QProcess.Running
|
||||
|
||||
def connect_output_callback(self, process: QProcess,
|
||||
callback: Callable[[str], None]) -> None:
|
||||
"""
|
||||
Подключает callback для обработки вывода процесса.
|
||||
|
||||
Args:
|
||||
process: Процесс установки
|
||||
callback: Функция для обработки вывода (принимает строку)
|
||||
"""
|
||||
def read_output():
|
||||
output = process.readAllStandardOutput().data().decode('utf-8', 'ignore')
|
||||
if output:
|
||||
callback(output)
|
||||
|
||||
process.readyReadStandardOutput.connect(read_output)
|
||||
|
||||
def connect_error_callback(self, process: QProcess,
|
||||
callback: Callable[[str], None]) -> None:
|
||||
"""
|
||||
Подключает callback для обработки ошибок.
|
||||
|
||||
Args:
|
||||
process: Процесс установки
|
||||
callback: Функция для обработки ошибок (принимает строку)
|
||||
"""
|
||||
def read_error():
|
||||
error = process.readAllStandardError().data().decode('utf-8', 'ignore')
|
||||
if error:
|
||||
callback(error)
|
||||
|
||||
process.readyReadStandardError.connect(read_error)
|
||||
|
||||
def get_install_script_for_folder(self, folder_path: str) -> Optional[str]:
|
||||
"""
|
||||
Возвращает путь к скрипту установки для папки.
|
||||
|
||||
Args:
|
||||
folder_path: Путь к папке скрипта
|
||||
|
||||
Returns:
|
||||
Путь к install.sh или None
|
||||
"""
|
||||
script_path = os.path.join(folder_path, 'install.sh')
|
||||
if os.path.exists(script_path):
|
||||
return script_path
|
||||
return None
|
||||
|
||||
def get_active_installations(self) -> Dict[str, QProcess]:
|
||||
"""
|
||||
Возвращает список активных процессов установки.
|
||||
|
||||
Returns:
|
||||
Словарь {script_path: QProcess}
|
||||
"""
|
||||
return {
|
||||
path: proc for path, proc in self._active_processes.items()
|
||||
if self.is_running(proc)
|
||||
}
|
||||
|
||||
def cancel_all(self) -> None:
|
||||
"""Отменяет все активные процессы установки."""
|
||||
for path, process in list(self._active_processes.items()):
|
||||
if self.is_running(process):
|
||||
self.stop_install(process)
|
||||
self._active_processes.clear()
|
||||
@@ -1,10 +1,14 @@
|
||||
"""
|
||||
Сервис для управления префиксами Wine.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.utils.export_utils import ExportUtils
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PrefixManager:
|
||||
@@ -54,18 +58,6 @@ class PrefixManager:
|
||||
prefix_path = self.get_prefix_path(prefix_name)
|
||||
return os.path.join(prefix_path, config_file)
|
||||
|
||||
def prefix_exists(self, prefix_name):
|
||||
"""
|
||||
Проверяет существование префикса.
|
||||
|
||||
Args:
|
||||
prefix_name: Имя префикса
|
||||
|
||||
Returns:
|
||||
True если префикс существует, False иначе
|
||||
"""
|
||||
return os.path.isdir(self.get_prefix_path(prefix_name))
|
||||
|
||||
def list_prefixes(self):
|
||||
"""
|
||||
Возвращает список всех префиксов.
|
||||
@@ -77,63 +69,19 @@ class PrefixManager:
|
||||
return []
|
||||
|
||||
try:
|
||||
return sorted([
|
||||
name for name in os.listdir(self.prefixes_path)
|
||||
if os.path.isdir(os.path.join(self.prefixes_path, name))
|
||||
])
|
||||
except OSError:
|
||||
prefixes = sorted(
|
||||
[
|
||||
name
|
||||
for name in os.listdir(self.prefixes_path)
|
||||
if os.path.isdir(os.path.join(self.prefixes_path, name))
|
||||
]
|
||||
)
|
||||
logger.debug("Найдено префиксов: %d (%s)", len(prefixes), self.prefixes_path)
|
||||
return prefixes
|
||||
except OSError as e:
|
||||
logger.warning("Не удалось прочитать директорию префиксов %s: %s", self.prefixes_path, e)
|
||||
return []
|
||||
|
||||
def create_prefix(self, prefix_name, wine_arch="win64", base_pfx="none"):
|
||||
"""
|
||||
Создает новый префикс.
|
||||
|
||||
Args:
|
||||
prefix_name: Имя префикса
|
||||
wine_arch: Архитектура ("win32" или "win64")
|
||||
base_pfx: Базовый шаблон префикса
|
||||
|
||||
Returns:
|
||||
True если создание успешно, False иначе
|
||||
"""
|
||||
if not prefix_name:
|
||||
return False, "Имя префикса не может быть пустым"
|
||||
|
||||
if not re.match(r'^[a-zA-Z0-9_-]+$', prefix_name):
|
||||
return False, "Имя префикса может содержать только латинские буквы, цифры, дефисы и знаки подчеркивания"
|
||||
|
||||
prefix_path = self.get_prefix_path(prefix_name)
|
||||
if os.path.exists(prefix_path):
|
||||
return False, f"Префикс с именем '{prefix_name}' уже существует"
|
||||
|
||||
# Создаем директорию префикса
|
||||
try:
|
||||
os.makedirs(prefix_path, exist_ok=True)
|
||||
return True, "Префикс создан"
|
||||
except OSError as e:
|
||||
return False, f"Ошибка создания префикса: {e}"
|
||||
|
||||
def delete_prefix(self, prefix_name):
|
||||
"""
|
||||
Удаляет префикс.
|
||||
|
||||
Args:
|
||||
prefix_name: Имя префикса
|
||||
|
||||
Returns:
|
||||
True если удаление успешно, False иначе
|
||||
"""
|
||||
prefix_path = self.get_prefix_path(prefix_name)
|
||||
|
||||
if not os.path.exists(prefix_path):
|
||||
return False, f"Префикс '{prefix_name}' не найден"
|
||||
|
||||
try:
|
||||
shutil.rmtree(prefix_path)
|
||||
return True, "Префикс удален"
|
||||
except OSError as e:
|
||||
return False, f"Ошибка удаления префикса: {e}"
|
||||
|
||||
def read_prefix_config(self, prefix_name, config_file="last.conf"):
|
||||
"""
|
||||
Читает конфигурацию префикса.
|
||||
@@ -152,18 +100,13 @@ class PrefixManager:
|
||||
|
||||
all_vars = {}
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
|
||||
match = re.match(r'export\s+(\w+)="([^"]*)"', line)
|
||||
if match:
|
||||
var_name = match.group(1)
|
||||
var_value = match.group(2)
|
||||
var_name, var_value = ExportUtils.parse_line(line)
|
||||
if var_name is not None:
|
||||
all_vars[var_name] = var_value
|
||||
except (IOError, OSError):
|
||||
except (IOError, OSError) as e:
|
||||
logger.warning("Не удалось прочитать конфиг префикса %s: %s", config_path, e)
|
||||
return {}
|
||||
|
||||
return all_vars
|
||||
@@ -188,49 +131,20 @@ class PrefixManager:
|
||||
|
||||
existing_lines = []
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
existing_lines = f.readlines()
|
||||
|
||||
updated_vars = set()
|
||||
new_lines = []
|
||||
new_lines = ExportUtils.update_lines(existing_lines, variables)
|
||||
|
||||
for line in existing_lines:
|
||||
stripped = line.strip()
|
||||
match = re.match(r'export\s+(\w+)="([^"]*)"', stripped)
|
||||
if match:
|
||||
var_name = match.group(1)
|
||||
if var_name in variables:
|
||||
new_lines.append(f'export {var_name}="{variables[var_name]}"\n')
|
||||
updated_vars.add(var_name)
|
||||
continue
|
||||
new_lines.append(line)
|
||||
|
||||
for var_name in variables:
|
||||
if var_name not in updated_vars:
|
||||
new_lines.append(f'export {var_name}="{variables[var_name]}"\n')
|
||||
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
f.writelines(new_lines)
|
||||
|
||||
logger.debug("Записана конфигурация префикса: %s", config_path)
|
||||
return True
|
||||
except (IOError, OSError) as e:
|
||||
print(f"Ошибка записи конфигурации: {e}")
|
||||
logger.error("Ошибка записи конфигурации: %s", e)
|
||||
return False
|
||||
|
||||
def get_prefix_component_version(self, prefix_name, component):
|
||||
"""
|
||||
Возвращает версию установленного компонента в префиксе.
|
||||
|
||||
Args:
|
||||
prefix_name: Имя префикса
|
||||
component: Имя компонента
|
||||
|
||||
Returns:
|
||||
Версия компонента или пустая строка
|
||||
"""
|
||||
config = self.read_prefix_config(prefix_name)
|
||||
return config.get(component, "")
|
||||
|
||||
def open_prefix_folder(self, prefix_name):
|
||||
"""
|
||||
Открывает папку префикса в файловом менеджере.
|
||||
@@ -247,24 +161,9 @@ class PrefixManager:
|
||||
return False, f"Директория префикса не найдена: {prefix_path}"
|
||||
|
||||
try:
|
||||
subprocess.Popen(['xdg-open', prefix_path])
|
||||
subprocess.Popen(["xdg-open", prefix_path])
|
||||
logger.debug("Открыта папка префикса: %s", prefix_path)
|
||||
return True, "Папка открыта"
|
||||
except Exception as e:
|
||||
logger.error("Не удалось открыть папку префикса %s: %s", prefix_path, e)
|
||||
return False, f"Ошибка открытия папки: {e}"
|
||||
|
||||
def get_prefixes_with_arch(self):
|
||||
"""
|
||||
Возвращает список префиксов с их архитектурой.
|
||||
|
||||
Returns:
|
||||
Список кортежей (имя, архитектура)
|
||||
"""
|
||||
prefixes = self.list_prefixes()
|
||||
result = []
|
||||
|
||||
for prefix_name in prefixes:
|
||||
config = self.read_prefix_config(prefix_name)
|
||||
arch = config.get('WINE_ARCH', 'unknown')
|
||||
result.append((prefix_name, arch))
|
||||
|
||||
return result
|
||||
@@ -1,159 +0,0 @@
|
||||
"""
|
||||
Сервис для выполнения и обработки процессов.
|
||||
"""
|
||||
import shlex
|
||||
from PyQt5.QtCore import QProcess, QProcessEnvironment
|
||||
|
||||
from winehelperqt.utils.process_utils import ProcessUtils
|
||||
|
||||
|
||||
class ProcessHandler:
|
||||
"""
|
||||
Сервис для выполнения процессов с обработкой вывода.
|
||||
|
||||
Предоставляет функциональность:
|
||||
- Запуск процессов
|
||||
- Обработка вывода (stdout/stderr)
|
||||
- Обработка завершения процесса
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
"""
|
||||
Инициализирует обработчик процессов.
|
||||
|
||||
Args:
|
||||
parent: Родительский QObject
|
||||
"""
|
||||
self.parent = parent
|
||||
self.process = None
|
||||
self.output_buffer = ""
|
||||
self.log_widget = None
|
||||
|
||||
def set_log_widget(self, log_widget):
|
||||
"""
|
||||
Устанавливает виджет для вывода логов.
|
||||
|
||||
Args:
|
||||
log_widget: QTextEdit для вывода
|
||||
"""
|
||||
self.log_widget = log_widget
|
||||
|
||||
def run_command(self, executable, arguments, log_widget=None):
|
||||
"""
|
||||
Запускает команду и выводит лог.
|
||||
|
||||
Args:
|
||||
executable: Путь к исполняемому файлу
|
||||
arguments: Список аргументов
|
||||
log_widget: QTextEdit для вывода (опционально)
|
||||
"""
|
||||
if log_widget:
|
||||
self.log_widget = log_widget
|
||||
|
||||
self.process = QProcess(self.parent)
|
||||
self.process.setProcessChannelMode(QProcess.MergedChannels)
|
||||
self.process.setProcessEnvironment(QProcessEnvironment.systemEnvironment())
|
||||
|
||||
# Логирование команды
|
||||
if self.log_widget:
|
||||
cmd_line = f"Выполнение: {shlex.quote(executable)} {' '.join(shlex.quote(a) for a in arguments)}"
|
||||
self._append_to_log(cmd_line)
|
||||
|
||||
self.process.start(executable, arguments)
|
||||
return self.process
|
||||
|
||||
def run_simple_command(self, executable, arguments, log_widget=None):
|
||||
"""
|
||||
Запускает простую команду без сложной обработки.
|
||||
|
||||
Args:
|
||||
executable: Путь к исполняемому файлу
|
||||
arguments: Список аргументов
|
||||
log_widget: QTextEdit для вывода (опционально)
|
||||
"""
|
||||
return self.run_command(executable, arguments, log_widget)
|
||||
|
||||
def _append_to_log(self, text, is_error=False):
|
||||
"""Добавляет текст в лог."""
|
||||
if not self.log_widget:
|
||||
return
|
||||
|
||||
if is_error:
|
||||
self.log_widget.append(f'<span style="color:red;">{text}</span>')
|
||||
else:
|
||||
self.log_widget.append(text)
|
||||
|
||||
def get_process(self):
|
||||
"""Возвращает текущий процесс."""
|
||||
return self.process
|
||||
|
||||
def is_running(self):
|
||||
"""Проверяет, запущен ли процесс."""
|
||||
return self.process and self.process.state() == QProcess.Running
|
||||
|
||||
def terminate(self):
|
||||
"""Завершает процесс."""
|
||||
ProcessUtils.stop_process(self.process)
|
||||
|
||||
def kill(self):
|
||||
"""Убивает процесс."""
|
||||
ProcessUtils.stop_process(self.process)
|
||||
|
||||
|
||||
class CommandRunner:
|
||||
"""
|
||||
Утилита для запуска команд winehelper.
|
||||
"""
|
||||
|
||||
def __init__(self, winehelper_path):
|
||||
"""
|
||||
Инициализирует исполнитель команд.
|
||||
|
||||
Args:
|
||||
winehelper_path: Путь к скрипту winehelper
|
||||
"""
|
||||
self.winehelper_path = winehelper_path
|
||||
|
||||
def run(self, command, arguments, log_widget=None):
|
||||
"""
|
||||
Запускает команду winehelper.
|
||||
|
||||
Args:
|
||||
command: Имя команды
|
||||
arguments: Дополнительные аргументы
|
||||
log_widget: QTextEdit для вывода (опционально)
|
||||
"""
|
||||
process_handler = ProcessHandler()
|
||||
process_handler.set_log_widget(log_widget)
|
||||
|
||||
args = [command] + arguments
|
||||
return process_handler.run_command(self.winehelper_path, args, log_widget)
|
||||
|
||||
def remove_prefix(self, prefix_name, log_widget=None):
|
||||
"""
|
||||
Удаляет префикс.
|
||||
|
||||
Args:
|
||||
prefix_name: Имя префикса
|
||||
log_widget: QTextEdit для вывода (опционально)
|
||||
"""
|
||||
return self.run("remove-prefix", [prefix_name, "--force"], log_widget)
|
||||
|
||||
def create_base_prefix(self, prefix_name, log_widget=None):
|
||||
"""
|
||||
Создает базовый префикс.
|
||||
|
||||
Args:
|
||||
prefix_name: Имя префикса
|
||||
log_widget: QTextEdit для вывода (опционально)
|
||||
"""
|
||||
return self.run("create-base-pfx", [prefix_name], log_widget)
|
||||
|
||||
def clear_winetricks_cache(self, log_widget=None):
|
||||
"""
|
||||
Очищает кеш winetricks.
|
||||
|
||||
Args:
|
||||
log_widget: QTextEdit для вывода (опционально)
|
||||
"""
|
||||
return self.run("clear-winetricks-cache", ["--force"], log_widget)
|
||||
@@ -8,17 +8,22 @@
|
||||
- Управление тестовыми скриптами
|
||||
- Кэширование информации о скриптах
|
||||
"""
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.parsers.script_parser import ScriptParser
|
||||
from winehelperqt.utils.cache_service import CacheService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScriptInfo:
|
||||
"""Информация о скрипте установки."""
|
||||
|
||||
name: str # Имя программы
|
||||
folder: str # Путь к папке скрипта
|
||||
script_path: str # Путь к install.sh
|
||||
@@ -26,19 +31,10 @@ class ScriptInfo:
|
||||
icons: List[str] # Список путей к иконкам
|
||||
url: str # URL для скачивания (если есть)
|
||||
|
||||
def icon_paths(self) -> List[str]:
|
||||
"""Возвращает полные пути к иконкам."""
|
||||
return self.icons
|
||||
|
||||
|
||||
class ScriptManager:
|
||||
"""Сервис для управления скриптами установки."""
|
||||
|
||||
# Константы для папок со скриптами
|
||||
AUTOINSTALL_DIR = "autoinstall"
|
||||
MANUALINSTALL_DIR = "manualinstall"
|
||||
TESTINSTALL_DIR = "testinstall"
|
||||
|
||||
# TTL для кэширования информации о скриптах (1 час)
|
||||
SCRIPT_INFO_CACHE_TTL = 3600
|
||||
|
||||
@@ -54,29 +50,6 @@ class ScriptManager:
|
||||
self.cache_service = cache_service or CacheService(ttl=self.SCRIPT_INFO_CACHE_TTL)
|
||||
self.parser = ScriptParser()
|
||||
|
||||
def get_script_folders(self, folder_name: str) -> List[str]:
|
||||
"""
|
||||
Возвращает список папок со скриптами.
|
||||
|
||||
Args:
|
||||
folder_name: Имя папки (autoinstall, manualinstall, testinstall)
|
||||
|
||||
Returns:
|
||||
Список путей к папкам со скриптами
|
||||
"""
|
||||
folder_path = os.path.join(self.data_path, folder_name)
|
||||
|
||||
if not os.path.isdir(folder_path):
|
||||
return []
|
||||
|
||||
try:
|
||||
return sorted([
|
||||
os.path.join(folder_path, d) for d in os.listdir(folder_path)
|
||||
if os.path.isdir(os.path.join(folder_path, d))
|
||||
])
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
def get_script_info(self, script_path: str) -> Optional[ScriptInfo]:
|
||||
"""
|
||||
Получает информацию о скрипте установки.
|
||||
@@ -93,17 +66,22 @@ class ScriptManager:
|
||||
# Проверяем кэш
|
||||
cached = self.cache_service.get(cache_key)
|
||||
if cached is not None:
|
||||
logger.debug("Информация о скрипте взята из кэша: %s", script_path)
|
||||
return cached
|
||||
|
||||
logger.debug("Парсим информацию из скрипта: %s", script_path)
|
||||
|
||||
# Проверяем существование файла
|
||||
if not os.path.exists(script_path):
|
||||
logger.debug("Файл скрипта не найден: %s", script_path)
|
||||
return None
|
||||
|
||||
# Парсим информацию из скрипта
|
||||
prog_name = self.parser.extract_prog_name_from_script(script_path)
|
||||
description = self.parser.extract_info_ru(script_path)
|
||||
icons = self.parser.extract_icons_from_script(script_path)
|
||||
url = self.parser.extract_prog_url_from_script(script_path)
|
||||
# Парсим информацию из скрипта одним проходом по файлу
|
||||
parsed = self.parser.parse_script(script_path)
|
||||
prog_name = parsed["prog_name"]
|
||||
description = parsed["description"]
|
||||
icons = parsed["icons"]
|
||||
url = parsed["url"]
|
||||
|
||||
# Получаем папку скрипта
|
||||
script_folder = os.path.dirname(script_path)
|
||||
@@ -111,6 +89,7 @@ class ScriptManager:
|
||||
# Если имя программы не найдено, используем имя папки как fallback
|
||||
if not prog_name:
|
||||
prog_name = os.path.basename(script_folder)
|
||||
logger.debug("Имя программы не найдено в %s, используем имя папки: %s", script_path, prog_name)
|
||||
|
||||
script_info = ScriptInfo(
|
||||
name=prog_name,
|
||||
@@ -118,103 +97,16 @@ class ScriptManager:
|
||||
script_path=script_path,
|
||||
description=description or "",
|
||||
icons=icons or [],
|
||||
url=url or ""
|
||||
url=url or "",
|
||||
)
|
||||
|
||||
# Сохраняем в кэш
|
||||
self.cache_service.set(cache_key, script_info)
|
||||
|
||||
logger.debug("Информация о скрипте сохранена в кэш: %s -> %s", script_path, prog_name)
|
||||
|
||||
return script_info
|
||||
|
||||
def list_available_apps(self, folder_name: str) -> List[ScriptInfo]:
|
||||
"""
|
||||
Возвращает список доступных приложений из папки.
|
||||
|
||||
Args:
|
||||
folder_name: Имя папки со скриптами
|
||||
|
||||
Returns:
|
||||
Список ScriptInfo
|
||||
"""
|
||||
folders = self.get_script_folders(folder_name)
|
||||
apps = []
|
||||
|
||||
for folder in folders:
|
||||
script_file = os.path.join(folder, 'install.sh')
|
||||
if os.path.exists(script_file):
|
||||
info = self.get_script_info(script_file)
|
||||
if info:
|
||||
apps.append(info)
|
||||
|
||||
return apps
|
||||
|
||||
def get_autoinstall_scripts(self) -> List[ScriptInfo]:
|
||||
"""Возвращает список скриптов для автоматической установки."""
|
||||
return self.list_available_apps(self.AUTOINSTALL_DIR)
|
||||
|
||||
def get_manualinstall_scripts(self) -> List[ScriptInfo]:
|
||||
"""Возвращает список скриптов для ручной установки."""
|
||||
return self.list_available_apps(self.MANUALINSTALL_DIR)
|
||||
|
||||
def get_testinstall_scripts(self) -> List[ScriptInfo]:
|
||||
"""Возвращает список скриптов для тестовой установки."""
|
||||
return self.list_available_apps(self.TESTINSTALL_DIR)
|
||||
|
||||
def search_scripts(self, scripts: List[ScriptInfo], search_text: str) -> List[ScriptInfo]:
|
||||
"""
|
||||
Ищет скрипты по названию и описанию.
|
||||
|
||||
Args:
|
||||
scripts: Список скриптов для поиска
|
||||
search_text: Текст для поиска
|
||||
|
||||
Returns:
|
||||
Отфильтрованный список скриптов
|
||||
"""
|
||||
if not search_text or not search_text.strip():
|
||||
return scripts
|
||||
|
||||
search_lower = search_text.lower()
|
||||
return [
|
||||
script for script in scripts
|
||||
if search_lower in script.name.lower() or
|
||||
search_lower in script.description.lower()
|
||||
]
|
||||
|
||||
def get_script_by_name(self, name: str, folder_name: str) -> Optional[ScriptInfo]:
|
||||
"""
|
||||
Находит скрипт по имени в указанной папке.
|
||||
|
||||
Args:
|
||||
name: Имя скрипта
|
||||
folder_name: Имя папки
|
||||
|
||||
Returns:
|
||||
ScriptInfo или None
|
||||
"""
|
||||
scripts = self.list_available_apps(folder_name)
|
||||
for script in scripts:
|
||||
if script.name.lower() == name.lower():
|
||||
return script
|
||||
return None
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Очищает кэш информации о скриптах."""
|
||||
self.cache_service.clear()
|
||||
|
||||
def invalidate_script_cache(self, script_path: str) -> bool:
|
||||
"""
|
||||
Удаляет конкретный скрипт из кэша.
|
||||
|
||||
Args:
|
||||
script_path: Путь к скрипту
|
||||
|
||||
Returns:
|
||||
True если запись была в кэше
|
||||
"""
|
||||
cache_key = self._get_script_cache_key(script_path)
|
||||
return self.cache_service.delete(cache_key)
|
||||
|
||||
def _get_script_cache_key(self, script_path: str) -> str:
|
||||
"""
|
||||
Создаёт ключ кэша для скрипта на основе пути и времени модификации.
|
||||
@@ -228,5 +120,6 @@ class ScriptManager:
|
||||
try:
|
||||
mtime = os.path.getmtime(script_path)
|
||||
return f"script_info:{script_path}:{mtime}"
|
||||
except OSError:
|
||||
except OSError as e:
|
||||
logger.warning("Не удалось получить время модификации %s: %s", script_path, e)
|
||||
return f"script_info:{script_path}"
|
||||
@@ -7,10 +7,12 @@
|
||||
- Backup/restore настроек
|
||||
- Загрузка настроек по умолчанию
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
from typing import Dict, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SettingsManager:
|
||||
@@ -43,7 +45,9 @@ class SettingsManager:
|
||||
Returns:
|
||||
Словарь с настройками
|
||||
"""
|
||||
return self.prefix_manager.read_prefix_config(prefix_name)
|
||||
settings = self.prefix_manager.read_prefix_config(prefix_name)
|
||||
logger.debug("Загружены настройки префикса %s: %s", prefix_name, settings)
|
||||
return settings
|
||||
|
||||
def save_prefix_settings(self, prefix_name: str, settings: Dict[str, str]) -> bool:
|
||||
"""
|
||||
@@ -56,173 +60,5 @@ class SettingsManager:
|
||||
Returns:
|
||||
True если сохранение успешно
|
||||
"""
|
||||
logger.debug("Сохраняем настройки префикса %s: %s", prefix_name, settings)
|
||||
return self.prefix_manager.write_prefix_config(prefix_name, settings)
|
||||
|
||||
def load_app_settings(self, desktop_path: str) -> Dict[str, str]:
|
||||
"""
|
||||
Загружает дополнительные настройки приложения.
|
||||
|
||||
Args:
|
||||
desktop_path: Путь к .desktop файлу
|
||||
|
||||
Returns:
|
||||
Словарь с настройками приложения
|
||||
"""
|
||||
if not os.path.exists(desktop_path):
|
||||
return {}
|
||||
|
||||
settings = {}
|
||||
try:
|
||||
with open(desktop_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if '=' in line and not line.startswith('#'):
|
||||
key, value = line.split('=', 1)
|
||||
settings[key.strip()] = value.strip()
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
|
||||
return settings
|
||||
|
||||
def save_app_settings(self, desktop_path: str, settings: Dict[str, str]) -> bool:
|
||||
"""
|
||||
Сохраняет дополнительные настройки приложения.
|
||||
|
||||
Args:
|
||||
desktop_path: Путь к .desktop файлу
|
||||
settings: Словарь с настройками
|
||||
|
||||
Returns:
|
||||
True если сохранение успешно
|
||||
"""
|
||||
if not os.path.exists(desktop_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
# Читаем существующие настройки
|
||||
existing_settings = {}
|
||||
with open(desktop_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if '=' in line and not line.startswith('#'):
|
||||
key, value = line.split('=', 1)
|
||||
existing_settings[key.strip()] = value.strip()
|
||||
|
||||
# Объединяем с новыми настройками
|
||||
existing_settings.update(settings)
|
||||
|
||||
# Перезаписываем файл
|
||||
with open(desktop_path, 'w', encoding='utf-8') as f:
|
||||
f.write("[Desktop Entry]\n")
|
||||
for key, value in existing_settings.items():
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
return True
|
||||
|
||||
except (IOError, OSError) as e:
|
||||
print(f"Ошибка сохранения настроек: {e}")
|
||||
return False
|
||||
|
||||
def backup_settings(self, source_path: str, backup_dir: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Создаёт резервную копию файла настроек.
|
||||
|
||||
Args:
|
||||
source_path: Путь к файлу настроек
|
||||
backup_dir: Директория для бэкапа (по умолчанию - рядом с файлом)
|
||||
|
||||
Returns:
|
||||
Путь к файлу бэкапа или None
|
||||
"""
|
||||
if not os.path.exists(source_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
# Определяем директорию для бэкапа
|
||||
if backup_dir is None:
|
||||
backup_dir = os.path.join(
|
||||
os.path.dirname(source_path),
|
||||
"backups"
|
||||
)
|
||||
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
|
||||
# Формируем имя файла бэкапа
|
||||
filename = os.path.basename(source_path)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_filename = f"{filename}.{timestamp}.backup"
|
||||
backup_path = os.path.join(backup_dir, backup_filename)
|
||||
|
||||
# Копируем файл
|
||||
shutil.copy2(source_path, backup_path)
|
||||
return backup_path
|
||||
|
||||
except (IOError, OSError) as e:
|
||||
print(f"Ошибка создания бэкапа: {e}")
|
||||
return None
|
||||
|
||||
def restore_settings(self, backup_path: str, target_path: str) -> bool:
|
||||
"""
|
||||
Восстанавливает настройки из бэкапа.
|
||||
|
||||
Args:
|
||||
backup_path: Путь к файлу бэкапа
|
||||
target_path: Путь для восстановления
|
||||
|
||||
Returns:
|
||||
True если восстановление успешно
|
||||
"""
|
||||
if not os.path.exists(backup_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
# Создаём директорию назначения
|
||||
target_dir = os.path.dirname(target_path)
|
||||
if target_dir:
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
# Копируем файл бэкапа
|
||||
shutil.copy2(backup_path, target_path)
|
||||
return True
|
||||
|
||||
except (IOError, OSError) as e:
|
||||
print(f"Ошибка восстановления настроек: {e}")
|
||||
return False
|
||||
|
||||
def get_default_settings(self, prefix_name: str) -> Dict[str, str]:
|
||||
"""
|
||||
Возвращает настройки по умолчанию для префикса.
|
||||
|
||||
Args:
|
||||
prefix_name: Имя префикса
|
||||
|
||||
Returns:
|
||||
Словарь с настройками по умолчанию
|
||||
"""
|
||||
return {
|
||||
'WINE_ARCH': 'win64',
|
||||
'WINEESYNC': '0',
|
||||
'WINEFSYNC': '0',
|
||||
'WH_WINDOWS_VER': '10',
|
||||
'WH_USE_SHADER_CACHE': '1',
|
||||
'WH_USE_MESA_GL_OVERRIDE': '0',
|
||||
'WH_USE_WINE_DXGI': '0',
|
||||
'WINE_LARGE_ADDRESS_AWARE': '1',
|
||||
'WINE_FULLSCREEN_FSR': '1',
|
||||
}
|
||||
|
||||
def merge_settings(self, base: Dict[str, str],
|
||||
override: Dict[str, str]) -> Dict[str, str]:
|
||||
"""
|
||||
Объединяет две конфигурации настроек.
|
||||
|
||||
Args:
|
||||
base: Базовые настройки
|
||||
override: Переопределяющие настройки
|
||||
|
||||
Returns:
|
||||
Объединённый словарь
|
||||
"""
|
||||
result = base.copy()
|
||||
result.update(override)
|
||||
return result
|
||||
@@ -1,13 +1,22 @@
|
||||
"""
|
||||
Сервисы для управления кэшем и компонентами Winetricks.
|
||||
"""
|
||||
import os
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.parsers.winetricks_parser import WinetricksOutputParser
|
||||
from winehelperqt.utils.cache_service import FileCacheService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class NamedFileCacheService(FileCacheService):
|
||||
def _get_cache_path(self, key: str) -> Path:
|
||||
return self.cache_dir / f"{key}.cache"
|
||||
|
||||
|
||||
class WinetricksCacheService:
|
||||
"""
|
||||
@@ -29,10 +38,10 @@ class WinetricksCacheService:
|
||||
"""
|
||||
self.cache_dir = Path(cache_dir) if cache_dir else self.CACHE_DIR
|
||||
self.cache_ttl = cache_ttl if cache_ttl is not None else self.DEFAULT_CACHE_TTL_SECONDS
|
||||
self._ensure_cache_dir()
|
||||
self._file_cache = FileCacheService(cache_dir=str(self.cache_dir), cache_ttl=self.cache_ttl)
|
||||
self.ensure_cache_dir()
|
||||
self._file_cache = NamedFileCacheService(cache_dir=str(self.cache_dir), cache_ttl=self.cache_ttl)
|
||||
|
||||
def _ensure_cache_dir(self):
|
||||
def ensure_cache_dir(self):
|
||||
"""Создает директорию кэша если она не существует."""
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -45,11 +54,12 @@ class WinetricksCacheService:
|
||||
"""
|
||||
try:
|
||||
hasher = hashlib.sha256()
|
||||
with open(winetricks_path, 'rb') as f:
|
||||
with open(winetricks_path, "rb") as f:
|
||||
while chunk := f.read(4096):
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest()
|
||||
except (IOError, OSError):
|
||||
except (IOError, OSError) as e:
|
||||
logger.warning("Не удалось вычислить хэш winetricks (%s): %s", winetricks_path, e)
|
||||
return None
|
||||
|
||||
def get_cached_data(self, category, winetricks_path):
|
||||
@@ -62,13 +72,20 @@ class WinetricksCacheService:
|
||||
"""
|
||||
cached = self._file_cache.get_json(category)
|
||||
if cached is None:
|
||||
logger.debug("Кэш категории winetricks '%s' отсутствует", category)
|
||||
return None
|
||||
|
||||
winetricks_hash = self._get_winetricks_hash(winetricks_path)
|
||||
if cached.get("hash") != winetricks_hash:
|
||||
logger.debug("Кэш категории winetricks '%s' устарел (изменился winetricks)", category)
|
||||
return None
|
||||
|
||||
return cached.get("output")
|
||||
output = cached.get("output")
|
||||
if isinstance(output, list):
|
||||
logger.debug("Кэш категории winetricks '%s': попадание (%d компонентов)", category, len(output))
|
||||
else:
|
||||
logger.debug("Кэш категории winetricks '%s': попадание", category)
|
||||
return output
|
||||
|
||||
def save_to_cache(self, category, output, winetricks_path):
|
||||
"""
|
||||
@@ -84,30 +101,14 @@ class WinetricksCacheService:
|
||||
if not winetricks_hash:
|
||||
return False
|
||||
|
||||
cache_data = {
|
||||
"hash": winetricks_hash,
|
||||
"output": output
|
||||
}
|
||||
|
||||
return self._file_cache.set_json(category, cache_data)
|
||||
|
||||
def clear_cache(self):
|
||||
"""
|
||||
Очищает весь кэш Winetricks.
|
||||
"""
|
||||
return self._file_cache.clear()
|
||||
|
||||
def is_cache_valid(self, category, winetricks_path):
|
||||
"""
|
||||
Проверяет актуальность кэша для категории.
|
||||
"""
|
||||
return self.get_cached_data(category, winetricks_path) is not None
|
||||
|
||||
def get_cache_age(self, category):
|
||||
"""
|
||||
Возвращает возраст кэша в секундах.
|
||||
"""
|
||||
return self._file_cache.get_cache_age(category)
|
||||
cache_data = {"hash": winetricks_hash, "output": output}
|
||||
saved = self._file_cache.set_json(category, cache_data)
|
||||
if saved:
|
||||
if isinstance(output, list):
|
||||
logger.debug("Кэш категории winetricks '%s' сохранён (%d компонентов)", category, len(output))
|
||||
else:
|
||||
logger.debug("Кэш категории winetricks '%s' сохранён", category)
|
||||
return saved
|
||||
|
||||
|
||||
class WinetricksLogReader:
|
||||
@@ -129,19 +130,6 @@ class WinetricksLogReader:
|
||||
"""
|
||||
Читает winetricks.log и возвращает множество установленных компонентов.
|
||||
"""
|
||||
return WinetricksOutputParser.parse_installed_verbs(self.log_path)
|
||||
|
||||
def is_verb_installed(self, verb):
|
||||
"""
|
||||
Проверяет, установлен ли указанный компонент.
|
||||
"""
|
||||
installed_verbs = self.get_installed_verbs()
|
||||
return verb in installed_verbs
|
||||
|
||||
def get_log_path(self):
|
||||
"""Возвращает путь к файлу лога."""
|
||||
return self.log_path
|
||||
|
||||
def log_exists(self):
|
||||
"""Проверяет существование файла лога."""
|
||||
return os.path.exists(self.log_path)
|
||||
verbs = WinetricksOutputParser.parse_installed_verbs(self.log_path)
|
||||
logger.debug("Установленные компоненты winetricks (%s): %d шт.", self.log_path, len(verbs))
|
||||
return verbs
|
||||
@@ -1,14 +1,3 @@
|
||||
"""
|
||||
Утилиты для winehelper GUI.
|
||||
"""
|
||||
from winehelperqt.utils.log_handler import LogProgressHandler, MultiLogHandler
|
||||
from winehelperqt.utils.cache_service import CacheService, FileCacheService
|
||||
from winehelperqt.utils.version_utils import sort_versions
|
||||
|
||||
__all__ = [
|
||||
'LogProgressHandler',
|
||||
'MultiLogHandler',
|
||||
'CacheService',
|
||||
'FileCacheService',
|
||||
'sort_versions',
|
||||
]
|
||||
@@ -7,11 +7,17 @@
|
||||
- File-based кэш для постоянных данных
|
||||
- Автоматическая очистка устаревших записей
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CacheEntry:
|
||||
@@ -68,12 +74,15 @@ class CacheService:
|
||||
"""
|
||||
entry = self._cache.get(key)
|
||||
if entry is None:
|
||||
logger.debug("Кэш '%s': промах (в памяти)", key)
|
||||
return default
|
||||
|
||||
if entry.is_expired():
|
||||
logger.debug("Кэш '%s': истёк (возраст %.0fс)", key, entry.age())
|
||||
self._cache.pop(key, None)
|
||||
return default
|
||||
|
||||
logger.debug("Кэш '%s': попадание (возраст %.0fс)", key, entry.age())
|
||||
return entry.data
|
||||
|
||||
def set(self, key: str, value: Any, ttl: Optional[float] = None) -> None:
|
||||
@@ -95,53 +104,11 @@ class CacheService:
|
||||
|
||||
effective_ttl = ttl if ttl is not None else self._default_ttl
|
||||
self._cache[key] = CacheEntry(value, effective_ttl)
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""
|
||||
Удаляет запись из кэша.
|
||||
|
||||
Args:
|
||||
key: Ключ для удаления
|
||||
|
||||
Returns:
|
||||
True если запись была удалена
|
||||
"""
|
||||
return self._cache.pop(key, None) is not None
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
"""
|
||||
Проверяет наличие ключа в кэше.
|
||||
|
||||
Args:
|
||||
key: Ключ
|
||||
|
||||
Returns:
|
||||
True если ключ существует и не истек
|
||||
"""
|
||||
entry = self._cache.get(key)
|
||||
if entry is None:
|
||||
return False
|
||||
|
||||
if entry.is_expired():
|
||||
self._cache.pop(key, None)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Очищает весь кэш."""
|
||||
self._cache.clear()
|
||||
|
||||
def size(self) -> int:
|
||||
"""Возвращает количество записей в кэше."""
|
||||
return len(self._cache)
|
||||
logger.debug("Кэш '%s': сохранено (TTL %s)", key, effective_ttl)
|
||||
|
||||
def _evict_expired(self) -> None:
|
||||
"""Удаляет все истекшие записи."""
|
||||
expired_keys = [
|
||||
key for key, entry in self._cache.items()
|
||||
if entry.is_expired()
|
||||
]
|
||||
expired_keys = [key for key, entry in self._cache.items() if entry.is_expired()]
|
||||
for key in expired_keys:
|
||||
del self._cache[key]
|
||||
|
||||
@@ -150,23 +117,9 @@ class CacheService:
|
||||
if not self._cache:
|
||||
return
|
||||
|
||||
oldest_key = min(
|
||||
self._cache.keys(),
|
||||
key=lambda k: self._cache[k].timestamp
|
||||
)
|
||||
oldest_key = min(self._cache.keys(), key=lambda k: self._cache[k].timestamp)
|
||||
del self._cache[oldest_key]
|
||||
|
||||
def cleanup(self) -> int:
|
||||
"""
|
||||
Очищает истекшие записи.
|
||||
|
||||
Returns:
|
||||
Количество удалённых записей
|
||||
"""
|
||||
before = len(self._cache)
|
||||
self._evict_expired()
|
||||
return before - len(self._cache)
|
||||
|
||||
|
||||
class FileCacheService:
|
||||
"""Сервис файлового кэширования для постоянных данных."""
|
||||
@@ -183,13 +136,13 @@ class FileCacheService:
|
||||
"""
|
||||
self.cache_dir = Path(cache_dir) if cache_dir else self._default_cache_dir()
|
||||
self.cache_ttl = cache_ttl if cache_ttl is not None else self.DEFAULT_CACHE_TTL_SECONDS
|
||||
self._ensure_cache_dir()
|
||||
self.ensure_cache_dir()
|
||||
|
||||
def _default_cache_dir(self) -> Path:
|
||||
"""Возвращает директорию кэша по умолчанию."""
|
||||
return Path.home() / ".cache" / "winehelper"
|
||||
|
||||
def _ensure_cache_dir(self) -> None:
|
||||
def ensure_cache_dir(self) -> None:
|
||||
"""Создаёт директорию кэша, если она не существует."""
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -221,23 +174,26 @@ class FileCacheService:
|
||||
cache_path = self._get_cache_path(key)
|
||||
|
||||
if not cache_path.exists():
|
||||
logger.debug("Файловый кэш '%s': промах", key)
|
||||
return default
|
||||
|
||||
try:
|
||||
with open(cache_path, 'r', encoding='utf-8') as f:
|
||||
with open(cache_path, "r", encoding="utf-8") as f:
|
||||
cache_data = json.load(f)
|
||||
|
||||
# Проверяем возраст кэша
|
||||
cache_age = time.time() - cache_data.get("timestamp", 0)
|
||||
if cache_age < self.cache_ttl:
|
||||
logger.debug("Файловый кэш '%s': попадание (возраст %.0fс)", key, cache_age)
|
||||
return cache_data.get("data")
|
||||
else:
|
||||
# Кэш устарел, удаляем
|
||||
cache_path.unlink(missing_ok=True)
|
||||
return default
|
||||
|
||||
# Кэш устарел, удаляем
|
||||
logger.debug("Файловый кэш '%s': устарел (возраст %.0fс)", key, cache_age)
|
||||
cache_path.unlink(missing_ok=True)
|
||||
return default
|
||||
|
||||
except (json.JSONDecodeError, IOError, KeyError) as e:
|
||||
print(f"Кэш '{key}' повреждён: {e}")
|
||||
logger.warning("Кэш '%s' повреждён: %s", key, e)
|
||||
return default
|
||||
|
||||
def set_json(self, key: str, data: Any) -> bool:
|
||||
@@ -253,82 +209,16 @@ class FileCacheService:
|
||||
"""
|
||||
cache_path = self._get_cache_path(key)
|
||||
|
||||
cache_data = {
|
||||
"timestamp": time.time(),
|
||||
"data": data
|
||||
}
|
||||
cache_data = {"timestamp": time.time(), "data": data}
|
||||
|
||||
try:
|
||||
with open(cache_path, 'w', encoding='utf-8') as f:
|
||||
# Создаём файл с правами 0o600, чтобы другие пользователи
|
||||
# не могли читать чувствительные данные кэша.
|
||||
fd = os.open(cache_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(cache_data, f, ensure_ascii=False, indent=2)
|
||||
logger.debug("Файловый кэш '%s': сохранён в %s", key, cache_path)
|
||||
return True
|
||||
except (IOError, OSError) as e:
|
||||
print(f"Не удалось сохранить кэш '{key}': {e}")
|
||||
logger.error("Не удалось сохранить кэш '%s': %s", key, e)
|
||||
return False
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""
|
||||
Удаляет запись из файлового кэша.
|
||||
|
||||
Args:
|
||||
key: Ключ для удаления
|
||||
|
||||
Returns:
|
||||
True если запись была удалена
|
||||
"""
|
||||
cache_path = self._get_cache_path(key)
|
||||
try:
|
||||
cache_path.unlink(missing_ok=True)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def clear(self) -> bool:
|
||||
"""
|
||||
Очищает весь файловый кэш.
|
||||
|
||||
Returns:
|
||||
True если очистка успешна
|
||||
"""
|
||||
try:
|
||||
if self.cache_dir.exists():
|
||||
for file_path in self.cache_dir.glob("*.cache"):
|
||||
file_path.unlink()
|
||||
return True
|
||||
except (IOError, OSError) as e:
|
||||
print(f"Ошибка очистки кэша: {e}")
|
||||
return False
|
||||
|
||||
def get_cache_age(self, key: str) -> Optional[float]:
|
||||
"""
|
||||
Возвращает возраст кэша в секундах.
|
||||
|
||||
Args:
|
||||
key: Ключ
|
||||
|
||||
Returns:
|
||||
Возраст в секундах или None
|
||||
"""
|
||||
cache_path = self._get_cache_path(key)
|
||||
|
||||
if not cache_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(cache_path, 'r', encoding='utf-8') as f:
|
||||
cache_data = json.load(f)
|
||||
return time.time() - cache_data.get("timestamp", 0)
|
||||
except (json.JSONDecodeError, IOError, KeyError):
|
||||
return None
|
||||
|
||||
def is_valid(self, key: str) -> bool:
|
||||
"""
|
||||
Проверяет актуальность кэша.
|
||||
|
||||
Args:
|
||||
key: Ключ
|
||||
|
||||
Returns:
|
||||
True если кэш актуален
|
||||
"""
|
||||
return self.get_json(key) is not None
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Утилиты для чтения и записи .desktop файлов.
|
||||
|
||||
Реализует поддержку:
|
||||
- секций ([Desktop Entry], [Desktop Action ...] и др.)
|
||||
- экранированных символов (\\s, \\n, \\t, \\r, \\\\)
|
||||
- многострочных значений (продолжение строки с отступом)
|
||||
- сохранения регистра ключей (Name, Exec, Icon, Name[ru] и т.д.)
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from typing import Dict
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DesktopEntry:
|
||||
"""Простой reader/writer для .desktop файлов по спецификации XDG."""
|
||||
|
||||
_ESCAPE_MAP = {
|
||||
"\\s": " ",
|
||||
"\\n": "\n",
|
||||
"\\t": "\t",
|
||||
"\\r": "\r",
|
||||
"\\\\": "\\",
|
||||
}
|
||||
_REVERSE_ESCAPE_MAP = {
|
||||
"\n": "\\n",
|
||||
"\t": "\\t",
|
||||
"\r": "\\r",
|
||||
"\\": "\\\\",
|
||||
}
|
||||
|
||||
def __init__(self, path: str):
|
||||
"""
|
||||
Args:
|
||||
path: Путь к .desktop файлу.
|
||||
"""
|
||||
self.path = path
|
||||
self.sections: Dict[str, "OrderedDict[str, str]"] = OrderedDict()
|
||||
|
||||
def load(self) -> "DesktopEntry":
|
||||
"""Загружает .desktop файл."""
|
||||
self.sections = OrderedDict()
|
||||
|
||||
if not os.path.exists(self.path):
|
||||
return self
|
||||
|
||||
current_section = None
|
||||
current_key = None
|
||||
|
||||
with open(self.path, "r", encoding="utf-8") as f:
|
||||
for raw_line in f:
|
||||
line = raw_line.rstrip("\n").rstrip("\r")
|
||||
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
section_match = re.match(r"^\[([^\]]+)\]\s*$", stripped)
|
||||
if section_match:
|
||||
current_section = section_match.group(1)
|
||||
self.sections.setdefault(current_section, OrderedDict())
|
||||
current_key = None
|
||||
continue
|
||||
|
||||
if line[0] in " \t" and current_key is not None and current_section is not None:
|
||||
# продолжение многострочного значения
|
||||
continuation = self._unescape(stripped)
|
||||
current_value = self.sections[current_section][current_key]
|
||||
self.sections[current_section][current_key] = current_value + "\n" + continuation
|
||||
continue
|
||||
|
||||
if "=" in stripped:
|
||||
key, value = stripped.split("=", 1)
|
||||
key = key.strip()
|
||||
value = self._unescape(value.strip())
|
||||
|
||||
if current_section is None:
|
||||
current_section = "Desktop Entry"
|
||||
self.sections.setdefault(current_section, OrderedDict())
|
||||
|
||||
self.sections[current_section][key] = value
|
||||
current_key = key
|
||||
|
||||
return self
|
||||
|
||||
def get(self, section: str, key: str, default=None):
|
||||
"""Возвращает значение ключа из секции."""
|
||||
return self.sections.get(section, OrderedDict()).get(key, default)
|
||||
|
||||
def section(self, section: str) -> "OrderedDict[str, str]":
|
||||
"""Возвращает секцию как OrderedDict."""
|
||||
return self.sections.setdefault(section, OrderedDict())
|
||||
|
||||
@classmethod
|
||||
def _unescape(cls, value: str) -> str:
|
||||
"""Раскрывает escape-последовательности .desktop."""
|
||||
result = []
|
||||
i = 0
|
||||
while i < len(value):
|
||||
if value[i] == "\\" and i + 1 < len(value):
|
||||
seq = value[i : i + 2]
|
||||
if seq in cls._ESCAPE_MAP:
|
||||
result.append(cls._ESCAPE_MAP[seq])
|
||||
i += 2
|
||||
continue
|
||||
result.append(value[i])
|
||||
i += 1
|
||||
return "".join(result)
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Утилиты для работы с диалогами и виджетами.
|
||||
|
||||
Устраняет дублирование кода подтверждающих QMessageBox и проверок
|
||||
живости виджетов в GUI.
|
||||
"""
|
||||
|
||||
from winehelperqt.qt_compat import QMessageBox, QPushButton, QWidget, sip
|
||||
|
||||
|
||||
class DialogUtils:
|
||||
"""Утилиты для создания стандартных диалогов и проверки виджетов."""
|
||||
|
||||
@staticmethod
|
||||
def is_widget_deleted(widget) -> bool:
|
||||
"""
|
||||
Проверяет, удалён ли C++-объект виджета.
|
||||
|
||||
Args:
|
||||
widget: Виджет для проверки
|
||||
|
||||
Returns:
|
||||
True если объект удалён (или уже недоступен)
|
||||
"""
|
||||
try:
|
||||
return sip.isdeleted(widget)
|
||||
except (RuntimeError, AttributeError):
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def confirm(
|
||||
parent: QWidget,
|
||||
title: str,
|
||||
text: str,
|
||||
yes_text: str = "Да",
|
||||
no_text: str = "Нет",
|
||||
default_no: bool = True,
|
||||
icon: QMessageBox.Icon = QMessageBox.Icon.Question,
|
||||
) -> bool:
|
||||
"""
|
||||
Показывает подтверждающий диалог с двумя кнопками.
|
||||
|
||||
Args:
|
||||
parent: Родительский виджет
|
||||
title: Заголовок диалога
|
||||
text: Текст сообщения
|
||||
yes_text: Текст кнопки подтверждения
|
||||
no_text: Текст кнопки отмены
|
||||
default_no: True если кнопка по умолчанию — отмена
|
||||
icon: Иконка диалога
|
||||
|
||||
Returns:
|
||||
True если нажата кнопка подтверждения
|
||||
"""
|
||||
msg_box = QMessageBox(parent)
|
||||
msg_box.setWindowTitle(title)
|
||||
msg_box.setText(text)
|
||||
msg_box.setIcon(icon)
|
||||
|
||||
yes_button = QPushButton(yes_text)
|
||||
no_button = QPushButton(no_text)
|
||||
|
||||
msg_box.addButton(yes_button, QMessageBox.ButtonRole.YesRole)
|
||||
msg_box.addButton(no_button, QMessageBox.ButtonRole.NoRole)
|
||||
|
||||
if default_no:
|
||||
msg_box.setDefaultButton(no_button)
|
||||
else:
|
||||
msg_box.setDefaultButton(yes_button)
|
||||
|
||||
msg_box.exec()
|
||||
return msg_box.clickedButton() == yes_button
|
||||
|
||||
@staticmethod
|
||||
def confirm_destructive(
|
||||
parent: QWidget,
|
||||
title: str,
|
||||
text: str,
|
||||
yes_text: str = "Да, удалить",
|
||||
no_text: str = "Нет",
|
||||
) -> bool:
|
||||
"""
|
||||
Показывает подтверждающий диалог для деструктивных операций.
|
||||
|
||||
Args:
|
||||
parent: Родительский виджет
|
||||
title: Заголовок диалога
|
||||
text: Текст сообщения
|
||||
yes_text: Текст кнопки подтверждения
|
||||
no_text: Текст кнопки отмены
|
||||
|
||||
Returns:
|
||||
True если нажата кнопка подтверждения
|
||||
"""
|
||||
msg_box = QMessageBox(parent)
|
||||
msg_box.setWindowTitle(title)
|
||||
msg_box.setText(text)
|
||||
msg_box.setIcon(QMessageBox.Icon.Question)
|
||||
|
||||
yes_button = QPushButton(yes_text)
|
||||
no_button = QPushButton(no_text)
|
||||
|
||||
msg_box.addButton(yes_button, QMessageBox.ButtonRole.DestructiveRole)
|
||||
msg_box.addButton(no_button, QMessageBox.ButtonRole.RejectRole)
|
||||
msg_box.setDefaultButton(no_button)
|
||||
|
||||
msg_box.exec()
|
||||
return msg_box.clickedButton() == yes_button
|
||||
@@ -1,13 +1,15 @@
|
||||
"""
|
||||
Единый модуль для переменных окружения WineHelper.
|
||||
|
||||
Устраняет дублирование класса Var в нескольких файлах.
|
||||
Устраняет дублирование конфигурации окружения в нескольких файлах.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
class EnvironmentConfig:
|
||||
"""Полная конфигурация приложения из переменных окружения."""
|
||||
|
||||
# Переменные определяемые в скрипте winehelper
|
||||
SCRIPT_NAME = os.environ.get("SCRIPT_NAME")
|
||||
USER_WORK_PATH = os.environ.get("USER_WORK_PATH")
|
||||
@@ -22,7 +24,7 @@ class EnvironmentConfig:
|
||||
GENERAL = os.environ.get("GENERAL")
|
||||
WH_WINETRICKS = os.environ.get("WH_WINETRICKS")
|
||||
WH_DIST_DIR = os.environ.get("WH_DIST_DIR")
|
||||
WH_DEVEL = os.environ.get("WH_DEVEL") # "1" при тестовом запуске из репозитория
|
||||
|
||||
|
||||
# Alias
|
||||
Var = EnvironmentConfig
|
||||
# Константы GUI
|
||||
WH_BACKUP_LOG_DIR = os.path.join(os.path.expanduser("~"), "winehelper_backup_log")
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Утилиты для работы со строками export VAR=value.
|
||||
|
||||
Устраняет дублирование парсинга и форматирования конфигурационных строк
|
||||
в prefix_manager, script_parser и диалогах настроек.
|
||||
"""
|
||||
|
||||
import re
|
||||
import shlex
|
||||
from typing import Optional, Tuple
|
||||
|
||||
|
||||
class ExportUtils:
|
||||
"""Утилиты для парсинга и форматирования export-строк."""
|
||||
|
||||
EXPORT_PATTERN = re.compile(r"^(?:export\s+)?(\w+)=(.*)$")
|
||||
|
||||
@classmethod
|
||||
def parse_line(cls, line: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
Парсит строку вида 'export VAR=value' или 'VAR=value'.
|
||||
|
||||
Args:
|
||||
line: Строка для парсинга
|
||||
|
||||
Returns:
|
||||
Кортеж (имя_переменной, значение) или (None, None)
|
||||
"""
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or stripped.startswith("["):
|
||||
return None, None
|
||||
|
||||
match = cls.EXPORT_PATTERN.match(stripped)
|
||||
if not match:
|
||||
return None, None
|
||||
|
||||
var_name = match.group(1)
|
||||
raw_value = match.group(2).strip()
|
||||
try:
|
||||
parsed = shlex.split(raw_value)
|
||||
var_value = parsed[0] if parsed else ""
|
||||
except ValueError:
|
||||
var_value = raw_value.strip("\"'")
|
||||
|
||||
return var_name, var_value
|
||||
|
||||
@classmethod
|
||||
def format_line(cls, var_name: str, value: str, with_export: bool = True) -> str:
|
||||
"""
|
||||
Форматирует строку export VAR=value с экранированием.
|
||||
|
||||
Всегда использует двойные кавычки для совместимости с bash-функцией
|
||||
update_last_conf_var(), которая также пишет с двойными кавычками.
|
||||
|
||||
Args:
|
||||
var_name: Имя переменной
|
||||
value: Значение переменной
|
||||
with_export: Добавлять ли ключевое слово export
|
||||
|
||||
Returns:
|
||||
Отформатированная строка
|
||||
"""
|
||||
prefix = "export " if with_export else ""
|
||||
# Экранируем двойные кавычки внутри значения
|
||||
escaped = value.replace('"', '\\"')
|
||||
return f'{prefix}{var_name}="{escaped}"\n'
|
||||
|
||||
@classmethod
|
||||
def update_lines(cls, lines: list, variables: dict, with_export: bool = True) -> list:
|
||||
"""
|
||||
Обновляет список строк, заменяя существующие переменные и добавляя новые.
|
||||
|
||||
Args:
|
||||
lines: Исходные строки конфигурационного файла
|
||||
variables: Словарь переменных для обновления
|
||||
with_export: Добавлять ли ключевое слово export
|
||||
|
||||
Returns:
|
||||
Обновленный список строк
|
||||
"""
|
||||
updated_vars = set()
|
||||
new_lines = []
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
var_name, _ = cls.parse_line(stripped)
|
||||
if var_name and var_name in variables:
|
||||
new_lines.append(cls.format_line(var_name, variables[var_name], with_export))
|
||||
updated_vars.add(var_name)
|
||||
continue
|
||||
new_lines.append(line)
|
||||
|
||||
for var_name, value in variables.items():
|
||||
if var_name not in updated_vars:
|
||||
new_lines.append(cls.format_line(var_name, value, with_export))
|
||||
|
||||
return new_lines
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Помощники для создания шрифтов.
|
||||
|
||||
Семейство шрифта нигде в GUI не задаётся явно: используется системный шрифт
|
||||
пользователя (для моноширинного — systemFont(FixedFont)). Это даёт
|
||||
единообразие с остальным рабочим столом и не зависит от наличия
|
||||
конкретных семейств (Arial в ALT Linux отсутствует).
|
||||
"""
|
||||
|
||||
from winehelperqt.qt_compat import QFont, QFontDatabase
|
||||
|
||||
|
||||
def app_font(point_size: int, bold: bool = False) -> QFont:
|
||||
"""Шрифт системного семейства с заданным размером и весом."""
|
||||
font = QFont()
|
||||
font.setPointSize(point_size)
|
||||
if bold:
|
||||
font.setBold(True)
|
||||
return font
|
||||
|
||||
|
||||
def fixed_font(point_size: int) -> QFont:
|
||||
"""Системный моноширинный шрифт заданного размера (для логов и консолей)."""
|
||||
font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
|
||||
font.setPointSize(point_size)
|
||||
return font
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Утилиты для работы с сетками кнопок.
|
||||
|
||||
Устраняет дублирование кода фильтрации и перестроения QGridLayout
|
||||
в списках приложений и диалогах выбора версий.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from winehelperqt.qt_compat import (
|
||||
QAbstractButton,
|
||||
QCheckBox,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QScrollArea,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchableTabContent:
|
||||
"""Содержимое стандартной вкладки с полем поиска и сеткой кнопок с прокруткой."""
|
||||
|
||||
widget: QWidget # Главный виджет вкладки
|
||||
grid_layout: QGridLayout # Сеточный макет с кнопками
|
||||
search_edit: QLineEdit # Поле поиска
|
||||
scroll_area: QScrollArea # Область прокрутки сетки
|
||||
description_checkbox: Optional[QCheckBox] # Чекбокс «Искать в описании» или None
|
||||
empty_label: QLabel # Метка пустого состояния сетки
|
||||
|
||||
|
||||
class GridUtils:
|
||||
"""Утилиты для фильтрации кнопок в QGridLayout."""
|
||||
|
||||
DEFAULT_NUM_COLUMNS = 2
|
||||
|
||||
EMPTY_SEARCH_TEXT = "Ничего не найдено"
|
||||
|
||||
@staticmethod
|
||||
def _move_to_position(grid_layout: QGridLayout, widget: QWidget, row: int, column: int) -> None:
|
||||
"""
|
||||
Переносит виджет на позицию в сетке.
|
||||
|
||||
Если виджет уже занимает эту позицию, layout не трогаем: лишний addWidget
|
||||
на каждую клавишу поиска заставлял бы Qt пересчитывать весь макет.
|
||||
"""
|
||||
index = grid_layout.indexOf(widget)
|
||||
if index != -1:
|
||||
current_row, current_column, _, _ = grid_layout.getItemPosition(index)
|
||||
if current_row == row and current_column == column:
|
||||
return
|
||||
grid_layout.addWidget(widget, row, column)
|
||||
|
||||
@staticmethod
|
||||
def update_empty_state(label: QLabel, visible_count: int, search_text: str, default_text: str) -> None:
|
||||
"""Показывает/скрывает метку пустого состояния сетки.
|
||||
|
||||
При непустом поиске — «Ничего не найдено», при пустом поиске —
|
||||
default_text (например, «Нет установленных приложений»).
|
||||
"""
|
||||
if visible_count > 0:
|
||||
label.setVisible(False)
|
||||
else:
|
||||
label.setText(GridUtils.EMPTY_SEARCH_TEXT if search_text else default_text)
|
||||
label.setVisible(True)
|
||||
|
||||
@classmethod
|
||||
def filter_buttons(
|
||||
cls,
|
||||
buttons: Iterable[QWidget],
|
||||
search_text: str,
|
||||
grid_layout: Optional[QGridLayout] = None,
|
||||
num_columns: int = DEFAULT_NUM_COLUMNS,
|
||||
text_getter=None,
|
||||
) -> list:
|
||||
"""
|
||||
Фильтрует виджеты по тексту и перестраивает сетку.
|
||||
|
||||
Args:
|
||||
buttons: Список виджетов для фильтрации
|
||||
search_text: Текст для поиска
|
||||
grid_layout: QGridLayout для перестроения (опционально)
|
||||
num_columns: Количество колонок в сетке
|
||||
text_getter: Функция для получения текста виджета
|
||||
(по умолчанию используется text())
|
||||
|
||||
Returns:
|
||||
Список видимых виджетов
|
||||
"""
|
||||
search_text_lower = search_text.lower()
|
||||
visible_buttons = []
|
||||
|
||||
if text_getter is None:
|
||||
|
||||
def text_getter(btn):
|
||||
return btn.text() if hasattr(btn, "text") else ""
|
||||
|
||||
for btn in buttons:
|
||||
is_match = search_text_lower in text_getter(btn).lower()
|
||||
btn.setVisible(is_match)
|
||||
if is_match:
|
||||
visible_buttons.append(btn)
|
||||
|
||||
if grid_layout is not None:
|
||||
for i, btn in enumerate(visible_buttons):
|
||||
row, col = divmod(i, num_columns)
|
||||
cls._move_to_position(grid_layout, btn, row, col)
|
||||
|
||||
return visible_buttons
|
||||
|
||||
@classmethod
|
||||
def filter_frame_buttons(
|
||||
cls,
|
||||
buttons: Iterable[QAbstractButton],
|
||||
search_text: str,
|
||||
grid_layout: QGridLayout,
|
||||
num_columns: int = DEFAULT_NUM_COLUMNS,
|
||||
text_getter=None,
|
||||
) -> list:
|
||||
"""
|
||||
Фильтрует кнопки, обернутые в QFrame, по тексту и перестраивает сетку.
|
||||
|
||||
Args:
|
||||
buttons: Список кнопок внутри фреймов
|
||||
search_text: Текст для поиска
|
||||
grid_layout: QGridLayout для перестроения
|
||||
num_columns: Количество колонок в сетке
|
||||
text_getter: Функция для получения текста кнопки
|
||||
(по умолчанию используется text())
|
||||
|
||||
Returns:
|
||||
Список видимых фреймов
|
||||
"""
|
||||
search_text_lower = search_text.lower()
|
||||
visible_frames = []
|
||||
|
||||
if text_getter is None:
|
||||
|
||||
def text_getter(btn):
|
||||
return btn.text()
|
||||
|
||||
for btn in buttons:
|
||||
frame = btn.parent()
|
||||
if not isinstance(frame, QFrame):
|
||||
frame = btn
|
||||
|
||||
is_match = search_text_lower in text_getter(btn).lower()
|
||||
frame.setVisible(is_match)
|
||||
if is_match:
|
||||
visible_frames.append(frame)
|
||||
|
||||
for i, frame in enumerate(visible_frames):
|
||||
row, col = divmod(i, num_columns)
|
||||
cls._move_to_position(grid_layout, frame, row, col)
|
||||
|
||||
return visible_frames
|
||||
@@ -2,8 +2,17 @@
|
||||
Обработчик логов с поддержкой прогресс-баров.
|
||||
Обрабатывает вывод процессов с корректным отображением однострочного прогресса.
|
||||
"""
|
||||
|
||||
import re
|
||||
from PyQt5.QtGui import QTextCursor
|
||||
|
||||
from winehelperqt.logger import console_progress_end, console_progress_line, console_progress_write, get_logger
|
||||
from winehelperqt.qt_compat import QTextCursor
|
||||
from winehelperqt.utils.dialog_utils import DialogUtils
|
||||
|
||||
logger = get_logger(__name__)
|
||||
# Отдельный логгер для вывода внешних процессов (bash, wine),
|
||||
# чтобы в консоли отличать его от событий самого GUI
|
||||
process_logger = get_logger("process")
|
||||
|
||||
|
||||
class LogProgressHandler:
|
||||
@@ -14,7 +23,12 @@ class LogProgressHandler:
|
||||
заменяя предыдущие строки прогресса новыми, вместо добавления новых строк.
|
||||
"""
|
||||
|
||||
PROGRESS_PERCENT_PATTERN = re.compile(r'\d\s*%')
|
||||
PROGRESS_PERCENT_PATTERN = re.compile(r"\d\s*%")
|
||||
# Готовый прогресс-бар в отдельной строке (итоговое состояние загрузки):
|
||||
# символы бара (#, точка, пробелы) и проценты в конце строки
|
||||
PROGRESS_BAR_PATTERN = re.compile(r"^[#.\s]*\d+(?:[.,]\d+)?\s*%$")
|
||||
# Ярлык строки прогресса в диалоге
|
||||
progress_label = "Загрузка: "
|
||||
|
||||
def __init__(self, log_widget=None):
|
||||
"""
|
||||
@@ -44,30 +58,18 @@ class LogProgressHandler:
|
||||
self.output_buffer += raw_output
|
||||
|
||||
while True:
|
||||
idx_n = self.output_buffer.find('\n')
|
||||
idx_r = self.output_buffer.find('\r')
|
||||
idx_n = self.output_buffer.find("\n")
|
||||
idx_r = self.output_buffer.find("\r")
|
||||
|
||||
if idx_n == -1 and idx_r == -1:
|
||||
break
|
||||
|
||||
split_idx = min(idx for idx in [idx_n, idx_r] if idx != -1)
|
||||
line = self.output_buffer[:split_idx + 1]
|
||||
self.output_buffer = self.output_buffer[split_idx + 1:]
|
||||
line = self.output_buffer[: split_idx + 1]
|
||||
self.output_buffer = self.output_buffer[split_idx + 1 :]
|
||||
|
||||
self._process_log_line(line)
|
||||
|
||||
def process_line(self, line):
|
||||
"""
|
||||
Обрабатывает одну строку лога.
|
||||
|
||||
Args:
|
||||
line: Строка лога (может содержать '\r' или '\n')
|
||||
"""
|
||||
if not self.log_widget:
|
||||
return
|
||||
|
||||
self._process_log_line(line)
|
||||
|
||||
def finalize(self):
|
||||
"""
|
||||
Завершает обработку лога.
|
||||
@@ -76,15 +78,22 @@ class LogProgressHandler:
|
||||
if not self.log_widget:
|
||||
return
|
||||
|
||||
if self.output_buffer:
|
||||
# Виджет мог быть удалён, если диалог закрыли до завершения процесса:
|
||||
# операции с ним делать нельзя, но консольный прогресс и состояние
|
||||
# обработчика завершить всё равно нужно
|
||||
widget_alive = not DialogUtils.is_widget_deleted(self.log_widget)
|
||||
|
||||
if widget_alive and self.output_buffer:
|
||||
self._process_log_line(self.output_buffer)
|
||||
|
||||
if self.last_line_was_progress:
|
||||
console_progress_end()
|
||||
|
||||
if widget_alive and self.last_line_was_progress:
|
||||
cursor = self.log_widget.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
cursor.insertText("\n")
|
||||
|
||||
self._reset_state()
|
||||
self.reset_state()
|
||||
|
||||
def _process_log_line(self, line_with_delimiter):
|
||||
"""
|
||||
@@ -93,33 +102,55 @@ class LogProgressHandler:
|
||||
Args:
|
||||
line_with_delimiter: Строка с разделителем (\n или \r)
|
||||
"""
|
||||
is_progress_line = '\r' in line_with_delimiter
|
||||
is_progress_line = "\r" in line_with_delimiter
|
||||
|
||||
if is_progress_line:
|
||||
if not self.PROGRESS_PERCENT_PATTERN.search(line_with_delimiter):
|
||||
return
|
||||
|
||||
clean_line = line_with_delimiter.replace('\r', '').replace('\n', '').strip()
|
||||
clean_line = line_with_delimiter.replace("\r", "").replace("\n", "").strip()
|
||||
|
||||
if not clean_line:
|
||||
return
|
||||
|
||||
cursor = self.log_widget.textCursor()
|
||||
|
||||
# Удаляем предыдущую строку прогресса (она всегда последний блок).
|
||||
# Берём блок целиком (StartOfBlock..EndOfBlock), а не LineUnderCursor:
|
||||
# если блок перенесён (виджет уже строки — например, появился
|
||||
# вертикальный скроллбар), визуальная строка удалит только хвост
|
||||
# переноса, и фрагменты склеятся в одну длинную строку.
|
||||
# BlockUnderCursor не подходит: он включает в себя разделитель блока
|
||||
# (U+2029) и вместе с ним удаляет предыдущую строку лога.
|
||||
if is_progress_line and self.last_line_was_progress:
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
cursor.select(QTextCursor.LineUnderCursor)
|
||||
cursor.removeSelectedText()
|
||||
self._remove_last_progress_block()
|
||||
elif not is_progress_line and self.last_line_was_progress:
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
cursor.select(QTextCursor.LineUnderCursor)
|
||||
cursor.removeSelectedText()
|
||||
self._remove_last_progress_block()
|
||||
|
||||
add_newline = not is_progress_line
|
||||
self._append_to_log(clean_line, is_error=False, add_newline=add_newline)
|
||||
is_bar = is_progress_line or bool(self.PROGRESS_BAR_PATTERN.match(clean_line))
|
||||
# В диалоге строка прогресса начинается с ярлыка
|
||||
dialog_text = self.progress_label + clean_line if is_bar else clean_line
|
||||
self._append_to_log(dialog_text, is_error=False, add_newline=add_newline)
|
||||
|
||||
# Зеркалируем в консоль: прогресс — строкой лога с префиксом
|
||||
# с анимацией (tty) или строками лога (перенаправление);
|
||||
# итоговая строка-бар завершает ту же строку; обычные строки — сразу
|
||||
if is_progress_line:
|
||||
console_progress_write(clean_line)
|
||||
elif is_bar:
|
||||
console_progress_line(clean_line)
|
||||
else:
|
||||
process_logger.info("%s", clean_line)
|
||||
|
||||
self.last_line_was_progress = is_progress_line
|
||||
|
||||
def _remove_last_progress_block(self):
|
||||
"""Удаляет последний блок виджета (предыдущая строка прогресса)."""
|
||||
cursor = self.log_widget.textCursor()
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock)
|
||||
cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor)
|
||||
cursor.removeSelectedText()
|
||||
|
||||
def _append_to_log(self, text, is_error=False, add_newline=True):
|
||||
"""
|
||||
Добавляет сообщение в лог.
|
||||
@@ -133,7 +164,16 @@ class LogProgressHandler:
|
||||
return
|
||||
|
||||
cursor = self.log_widget.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
|
||||
# Если предыдущий текст был добавлен через QTextEdit.append() или
|
||||
# процессом без завершающего переноса, курсор окажется в конце блока.
|
||||
# Добавляем перенос строки, чтобы избежать слипания строк.
|
||||
if not cursor.atBlockStart():
|
||||
if is_error:
|
||||
cursor.insertText("\n")
|
||||
elif add_newline:
|
||||
text = "\n" + text
|
||||
|
||||
if is_error:
|
||||
cursor.insertHtml(f'<font color="red">{text}</font><br>')
|
||||
@@ -143,36 +183,11 @@ class LogProgressHandler:
|
||||
|
||||
self.log_widget.ensureCursorVisible()
|
||||
|
||||
def _reset_state(self):
|
||||
def reset_state(self):
|
||||
"""Сбрасывает состояние обработчика."""
|
||||
self.last_line_was_progress = False
|
||||
self.output_buffer = ""
|
||||
|
||||
def clear(self):
|
||||
"""Очищает лог и сбрасывает состояние."""
|
||||
if self.log_widget:
|
||||
self.log_widget.clear()
|
||||
self._reset_state()
|
||||
|
||||
def append_error(self, text):
|
||||
"""
|
||||
Добавляет сообщение об ошибке в лог.
|
||||
|
||||
Args:
|
||||
text: Текст ошибки
|
||||
"""
|
||||
self._append_to_log(text, is_error=True, add_newline=True)
|
||||
|
||||
def append_info(self, text, add_newline=True):
|
||||
"""
|
||||
Добавляет информационное сообщение в лог.
|
||||
|
||||
Args:
|
||||
text: Текст сообщения
|
||||
add_newline: True если добавить перенос строки
|
||||
"""
|
||||
self._append_to_log(text, is_error=False, add_newline=add_newline)
|
||||
|
||||
|
||||
class MultiLogHandler:
|
||||
"""
|
||||
@@ -212,19 +227,3 @@ class MultiLogHandler:
|
||||
LogProgressHandler или None
|
||||
"""
|
||||
return self.handlers.get(name)
|
||||
|
||||
def remove_handler(self, name):
|
||||
"""
|
||||
Удаляет обработчик по имени.
|
||||
|
||||
Args:
|
||||
name: Имя удаляемого обработчика
|
||||
"""
|
||||
if name in self.handlers:
|
||||
del self.handlers[name]
|
||||
|
||||
def clear_all(self):
|
||||
"""Очищает все обработчики."""
|
||||
for handler in self.handlers.values():
|
||||
handler.clear()
|
||||
self.handlers.clear()
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Построение HTML с информацией о префиксе для панели менеджера префиксов.
|
||||
|
||||
Вынесено из WineHelperGUI для уменьшения главного класса.
|
||||
"""
|
||||
|
||||
import html
|
||||
import os
|
||||
|
||||
from winehelperqt.parsers.winetricks_parser import WinetricksOutputParser
|
||||
|
||||
# Служебные компоненты winetricks, которые не показываем пользователю
|
||||
VERBS_TO_IGNORE = {
|
||||
"isolate_home",
|
||||
"winxp",
|
||||
"win7",
|
||||
"win10",
|
||||
"win11",
|
||||
"vista",
|
||||
"win2k",
|
||||
"win2k3",
|
||||
"win2k8",
|
||||
"win8",
|
||||
"win81",
|
||||
"workaround",
|
||||
"internal",
|
||||
}
|
||||
|
||||
# Карта для красивого отображения известных переменных префикса
|
||||
DISPLAY_MAP = {
|
||||
"WINEPREFIX": ("Путь", lambda v: v),
|
||||
"WINEARCH": ("Архитектура", lambda v: "64-bit" if v == "win64" else "32-bit"),
|
||||
"WH_WINE_USE": ("Версия Wine", lambda v: "Системная" if v == "system" else v),
|
||||
"BASE_PFX": ("Шаблон префикса", lambda v: "Не задан" if not v or v.lower() == "none" else v),
|
||||
"DXVK_VER": ("Версия DXVK", lambda v: "Не задана" if not v or v.lower() == "none" else v),
|
||||
"VKD3D_VER": ("Версия VKD3D", lambda v: "Не задана" if not v or v.lower() == "none" else v),
|
||||
"WINEESYNC": ("ESync", lambda v: "Включен" if v == "1" else "Выключен"),
|
||||
"WINEFSYNC": ("FSync", lambda v: "Включен" if v == "1" else "Выключен"),
|
||||
"WH_USE_CPCSP_PROXY": ("CPCSP_PROXY", lambda v: "Включен" if v == "1" else "Выключен"),
|
||||
"WH_USE_GRDWINE": ("Ключ Guardant", lambda v: "Включен" if v == "1" else "Выключен"),
|
||||
"WH_XDG_OPEN": ("Ассоциации файлов", lambda v: v if v and v not in ("0", "default") else "Не заданы"),
|
||||
"WH_WINDOWS_VER": ("Версия Windows", lambda v: f"Windows {v}" if v else "Не задана"),
|
||||
}
|
||||
|
||||
# Порядок отображения известных переменных
|
||||
DISPLAY_ORDER = [
|
||||
"WINEPREFIX",
|
||||
"WINEARCH",
|
||||
"WH_WINE_USE",
|
||||
"BASE_PFX",
|
||||
"DXVK_VER",
|
||||
"VKD3D_VER",
|
||||
"WINEESYNC",
|
||||
"WINEFSYNC",
|
||||
"WH_USE_CPCSP_PROXY",
|
||||
"WH_USE_GRDWINE",
|
||||
"WH_WINDOWS_VER",
|
||||
"WH_XDG_OPEN",
|
||||
]
|
||||
|
||||
|
||||
def _read_winetricks_verbs(prefix_dir):
|
||||
"""
|
||||
Читает список установленных компонентов из winetricks.log префикса.
|
||||
|
||||
Args:
|
||||
prefix_dir: Путь к директории префикса
|
||||
|
||||
Returns:
|
||||
Отсортированный список компонентов для отображения (без служебных)
|
||||
"""
|
||||
log_path = os.path.join(prefix_dir, "winetricks.log")
|
||||
installed_verbs = WinetricksOutputParser.parse_installed_verbs(log_path)
|
||||
return sorted(v for v in installed_verbs if v not in VERBS_TO_IGNORE)
|
||||
|
||||
|
||||
def build_prefix_info_html(prefix_name, all_vars, prefixes_dir):
|
||||
"""
|
||||
Строит HTML с информацией о префиксе для информационного блока.
|
||||
|
||||
Args:
|
||||
prefix_name: Имя префикса
|
||||
all_vars: Словарь переменных из last.conf (может быть пустым)
|
||||
prefixes_dir: Путь к директории с префиксами (для чтения winetricks.log)
|
||||
|
||||
Returns:
|
||||
HTML-строка для отображения
|
||||
"""
|
||||
if not all_vars:
|
||||
return f"<p>Файл конфигурации last.conf не найден или пуст для префикса '{html.escape(prefix_name)}'.</p>"
|
||||
|
||||
display_verbs = _read_winetricks_verbs(os.path.join(prefixes_dir, prefix_name))
|
||||
|
||||
html_content = '<p style="line-height: 1.3; font-size: 9pt;">'
|
||||
html_content += f"<b>Имя префикса:</b> {html.escape(prefix_name)}<br>"
|
||||
|
||||
# Отображаем известные переменные в заданном порядке
|
||||
for key in DISPLAY_ORDER:
|
||||
if key in all_vars:
|
||||
label, formatter = DISPLAY_MAP[key]
|
||||
value = formatter(all_vars[key])
|
||||
html_content += f"<b>{html.escape(label)}:</b> {html.escape(value)}<br>"
|
||||
|
||||
# Отображаем остальные (неизвестные) переменные
|
||||
other_vars_html = ""
|
||||
for key, value in sorted(all_vars.items()):
|
||||
if key not in DISPLAY_MAP:
|
||||
other_vars_html += f" <b>{html.escape(key)}:</b> {html.escape(value)}<br>"
|
||||
|
||||
if other_vars_html:
|
||||
html_content += "<br><b>Дополнительные параметры:</b><br>"
|
||||
html_content += other_vars_html
|
||||
|
||||
html_content += "<br><b>Компоненты (Winetricks):</b> "
|
||||
if display_verbs:
|
||||
# Используем span вместо div, чтобы избежать лишних отступов
|
||||
html_content += '<span style="max-height: 120px; overflow-y: auto;">'
|
||||
html_content += ", ".join(html.escape(v) for v in display_verbs)
|
||||
html_content += "</span>"
|
||||
else:
|
||||
html_content += "Не установлены"
|
||||
|
||||
html_content += "</p>"
|
||||
return html_content
|
||||
@@ -3,7 +3,8 @@
|
||||
|
||||
Устраняет дублирование кода остановки процессов в разных частях приложения.
|
||||
"""
|
||||
from PyQt5.QtCore import QProcess
|
||||
|
||||
from winehelperqt.qt_compat import QProcess, QProcessEnvironment, QTimer
|
||||
|
||||
|
||||
class ProcessUtils:
|
||||
@@ -12,10 +13,28 @@ class ProcessUtils:
|
||||
# Таймаут ожидания после terminate (мс)
|
||||
DEFAULT_TERMINATE_TIMEOUT = 3000
|
||||
|
||||
@staticmethod
|
||||
def prepare_process(process: QProcess, env_vars: dict = None) -> None:
|
||||
"""
|
||||
Настраивает QProcess: объединённый канал вывода и окружение
|
||||
с дополнительными переменными.
|
||||
|
||||
Args:
|
||||
process: Процесс для настройки
|
||||
env_vars: Дополнительные переменные окружения (опционально)
|
||||
"""
|
||||
process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
|
||||
env = QProcessEnvironment.systemEnvironment()
|
||||
if env_vars:
|
||||
for key, value in env_vars.items():
|
||||
env.insert(key, value)
|
||||
process.setProcessEnvironment(env)
|
||||
|
||||
@staticmethod
|
||||
def stop_process(process: QProcess, timeout: int = DEFAULT_TERMINATE_TIMEOUT) -> None:
|
||||
"""
|
||||
Останавливает QProcess: сначала terminate, потом kill если не завершился.
|
||||
Останавливает QProcess без блокировки UI-потока:
|
||||
сразу terminate, затем kill по таймауту, если процесс ещё жив.
|
||||
|
||||
Args:
|
||||
process: Процесс для остановки
|
||||
@@ -24,20 +43,15 @@ class ProcessUtils:
|
||||
if not process:
|
||||
return
|
||||
|
||||
if process.state() == QProcess.Running:
|
||||
if process.state() == QProcess.ProcessState.Running:
|
||||
process.terminate()
|
||||
if not process.waitForFinished(timeout):
|
||||
process.kill()
|
||||
QTimer.singleShot(timeout, lambda p=process: ProcessUtils._kill_if_running(p))
|
||||
|
||||
@staticmethod
|
||||
def is_running(process: QProcess) -> bool:
|
||||
"""
|
||||
Проверяет, запущен ли процесс.
|
||||
|
||||
Args:
|
||||
process: Процесс для проверки
|
||||
|
||||
Returns:
|
||||
True если процесс запущен
|
||||
"""
|
||||
return process is not None and process.state() == QProcess.Running
|
||||
def _kill_if_running(process: QProcess) -> None:
|
||||
"""Убивает процесс, если он не завершился после terminate."""
|
||||
try:
|
||||
if process and process.state() == QProcess.ProcessState.Running:
|
||||
process.kill()
|
||||
except RuntimeError:
|
||||
pass # объект QProcess уже удалён
|
||||
@@ -4,11 +4,11 @@
|
||||
Исправляет проблему лексикографической сортировки строк версий,
|
||||
где 'wine-9.0...' считалось больше чем 'wine-11.0...'.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
# Регулярное выражение для извлечения числовых частей версии
|
||||
_VERSION_PART_RE = re.compile(r'(\d+)')
|
||||
_VERSION_PART_RE = re.compile(r"(\d+)")
|
||||
|
||||
|
||||
def _version_key(version_string):
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
"""
|
||||
Виджеты для winehelper GUI.
|
||||
"""
|
||||
from winehelperqt.widgets.icon_manager import IconManager
|
||||
|
||||
__all__ = [
|
||||
'IconManager',
|
||||
]
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Вкладка «Справка» главного окна WineHelper.
|
||||
|
||||
Содержит подвкладки: Общее, Авторы, Лицензия, История изменений.
|
||||
"""
|
||||
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.qt_compat import QTabWidget, QTextBrowser, QTextEdit, QVBoxLayout, QWidget
|
||||
from winehelperqt.utils.environment import EnvironmentConfig
|
||||
from winehelperqt.utils.font_utils import fixed_font
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class HelpTab(QWidget):
|
||||
"""Вкладка «Справка» с подвкладками."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
help_layout = QVBoxLayout(self)
|
||||
help_layout.setContentsMargins(5, 5, 5, 5)
|
||||
|
||||
help_subtabs = QTabWidget()
|
||||
help_layout.addWidget(help_subtabs)
|
||||
|
||||
help_subtabs.addTab(self._create_general_subtab(), "Общее")
|
||||
help_subtabs.addTab(self._create_authors_subtab(), "Авторы")
|
||||
help_subtabs.addTab(self._create_license_subtab(), "Лицензия")
|
||||
help_subtabs.addTab(self._create_changelog_subtab(), "История изменений")
|
||||
|
||||
def _create_general_subtab(self):
|
||||
"""Создает подвкладку «Общее» с информацией из файла GENERAL."""
|
||||
general_tab = QWidget()
|
||||
general_layout = QVBoxLayout(general_tab)
|
||||
general_text = QTextBrowser()
|
||||
general_text.setOpenExternalLinks(True)
|
||||
|
||||
try:
|
||||
if not EnvironmentConfig.GENERAL or not os.path.exists(EnvironmentConfig.GENERAL):
|
||||
raise FileNotFoundError
|
||||
|
||||
with open(EnvironmentConfig.GENERAL, "r", encoding="utf-8") as f:
|
||||
general_content = f.read()
|
||||
|
||||
html_content = ""
|
||||
url_re = re.compile(r"(https?://[^\s]+)")
|
||||
|
||||
for line in general_content.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
html_content += "<br>"
|
||||
continue
|
||||
|
||||
line = html.escape(line)
|
||||
line = url_re.sub(r'<a href="\1">\1</a>', line)
|
||||
|
||||
if line.startswith("# "):
|
||||
html_content += f"<h2>{line[2:]}</h2>"
|
||||
elif line.startswith("Для "):
|
||||
html_content += f'<p style="margin-left: 10px;">• {line}</p>'
|
||||
else:
|
||||
html_content += f"<p>{line}</p>"
|
||||
|
||||
general_text.setHtml(html_content)
|
||||
except (FileNotFoundError, TypeError) as e:
|
||||
logger.warning("Не удалось загрузить общую информацию из %s: %s", EnvironmentConfig.GENERAL, e)
|
||||
general_text.setHtml(
|
||||
"<h2>Ошибка</h2>"
|
||||
"<p>Не удалось загрузить файл с общей информацией по пути:<br>"
|
||||
f"{EnvironmentConfig.GENERAL}</p>"
|
||||
)
|
||||
|
||||
general_layout.addWidget(general_text)
|
||||
return general_tab
|
||||
|
||||
def _create_authors_subtab(self):
|
||||
"""Создает подвкладку «Авторы»."""
|
||||
authors_tab = QWidget()
|
||||
authors_layout = QVBoxLayout(authors_tab)
|
||||
authors_text = QTextEdit()
|
||||
authors_text.setReadOnly(True)
|
||||
authors_text.setHtml(
|
||||
'<div style="text-align: center; font-size: 10pt;">'
|
||||
'<p><span style="font-size: 11pt;"><b>Разработчики</b></span><br>'
|
||||
"Михаил Тергоев (fidel)<br>"
|
||||
"Сергей Пальчех (minergenon)</p>"
|
||||
'<p><span style="font-size: 11pt;"><b>Помощники</b></span><br>'
|
||||
"Иван Мажукин (vanomj)</p>"
|
||||
'<p><span style="font-size: 11pt;"><b>Идея и поддержка</b></span><br>'
|
||||
"ООО "Базальт СПО"<br>"
|
||||
"ALT Linux Team</p>"
|
||||
"<br>"
|
||||
"<p>Отдельная благодарность всем, кто вносит свой вклад в "
|
||||
"развитие проекта,<br>"
|
||||
"тестирует и сообщает об ошибках!</p>"
|
||||
"</div>"
|
||||
)
|
||||
authors_layout.addWidget(authors_text)
|
||||
return authors_tab
|
||||
|
||||
def _create_license_subtab(self):
|
||||
"""Создает подвкладку «Лицензия» с текстом лицензии и списком сторонних компонентов."""
|
||||
license_tab = QWidget()
|
||||
license_layout = QVBoxLayout(license_tab)
|
||||
license_text = QTextBrowser()
|
||||
license_text.setOpenExternalLinks(True)
|
||||
|
||||
try:
|
||||
if not EnvironmentConfig.LICENSE_FILE:
|
||||
raise FileNotFoundError
|
||||
if not os.path.exists(EnvironmentConfig.LICENSE_FILE):
|
||||
raise FileNotFoundError
|
||||
|
||||
with open(EnvironmentConfig.LICENSE_FILE, "r", encoding="utf-8") as f:
|
||||
license_content = f.read()
|
||||
|
||||
escaped_license_content = html.escape(license_content)
|
||||
|
||||
license_html = (
|
||||
"<h2>Лицензия</h2>"
|
||||
"<pre style=\"font-family: 'DejaVu Sans Mono', monospace; "
|
||||
"font-size: 9pt; padding: 10px; border: 1px solid #a0a0a0; "
|
||||
'border-radius: 5px;">'
|
||||
f"{escaped_license_content}</pre>"
|
||||
"<hr>"
|
||||
"<h2>Сторонние компоненты</h2>"
|
||||
"<p>Некоторые компоненты, используемые или устанавливаемые "
|
||||
"данным ПО, могут иметь собственные лицензии. Пользователь "
|
||||
"несет полную ответственность за соблюдение этих лицензионных "
|
||||
"соглашений.</p>"
|
||||
"<p>Ниже приведен список основных сторонних компонентов и "
|
||||
"ссылки на их исходный код:</p>"
|
||||
)
|
||||
|
||||
# Читаем и парсим файл THIRD-PARTY
|
||||
third_party_html = ""
|
||||
third_party_file_path = EnvironmentConfig.THIRD_PARTY_FILE
|
||||
if third_party_file_path and os.path.exists(third_party_file_path):
|
||||
with open(third_party_file_path, "r", encoding="utf-8") as f_tp:
|
||||
third_party_content = f_tp.read()
|
||||
|
||||
# Преобразуем контент в HTML
|
||||
third_party_html += "<blockquote>"
|
||||
for line in third_party_content.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
third_party_html += "<br>"
|
||||
continue
|
||||
escaped_line = html.escape(line)
|
||||
if line.startswith("http"):
|
||||
third_party_html += (
|
||||
f" "
|
||||
f'<a href="{escaped_line}" style="font-size: 10pt;">'
|
||||
f"{escaped_line}</a><br>"
|
||||
)
|
||||
else:
|
||||
third_party_html += f"<b>{escaped_line}</b><br>"
|
||||
third_party_html += "</blockquote>"
|
||||
|
||||
license_text.setHtml(license_html + third_party_html)
|
||||
except (FileNotFoundError, TypeError) as e:
|
||||
logger.warning("Не удалось загрузить лицензию из %s: %s", EnvironmentConfig.LICENSE_FILE, e)
|
||||
license_text.setHtml(
|
||||
"<h2>Лицензия</h2>"
|
||||
"<p>Не удалось загрузить файл лицензии по пути:<br>"
|
||||
f"{EnvironmentConfig.LICENSE_FILE}</p>"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения файла лицензии %s: %s", EnvironmentConfig.LICENSE_FILE, e)
|
||||
license_text.setHtml(f"<h2>Лицензия</h2><p>Произошла ошибка при чтении файла лицензии:<br>{str(e)}</p>")
|
||||
|
||||
license_layout.addWidget(license_text)
|
||||
return license_tab
|
||||
|
||||
def _create_changelog_subtab(self):
|
||||
"""Создает подвкладку «История изменений» с содержимым файла CHANGELOG."""
|
||||
changelog_tab = QWidget()
|
||||
changelog_layout = QVBoxLayout(changelog_tab)
|
||||
changelog_text = QTextEdit()
|
||||
changelog_text.setReadOnly(True)
|
||||
changelog_text.setFont(fixed_font(9))
|
||||
|
||||
try:
|
||||
if not EnvironmentConfig.CHANGELOG_FILE or not os.path.exists(EnvironmentConfig.CHANGELOG_FILE):
|
||||
raise FileNotFoundError
|
||||
with open(EnvironmentConfig.CHANGELOG_FILE, "r", encoding="utf-8") as f:
|
||||
changelog_content = f.read()
|
||||
changelog_text.setText(changelog_content)
|
||||
except (FileNotFoundError, TypeError) as e:
|
||||
logger.warning("Не удалось загрузить историю изменений из %s: %s", EnvironmentConfig.CHANGELOG_FILE, e)
|
||||
changelog_text.setText(f"Файл истории изменений не найден по пути:\n{EnvironmentConfig.CHANGELOG_FILE}")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения истории изменений %s: %s", EnvironmentConfig.CHANGELOG_FILE, e)
|
||||
changelog_text.setText(f"Не удалось прочитать файл истории изменений:\n{str(e)}")
|
||||
|
||||
changelog_layout.addWidget(changelog_text)
|
||||
return changelog_tab
|
||||
@@ -8,12 +8,26 @@
|
||||
- Кэширование иконок
|
||||
- Извлечение иконок из .exe файлов
|
||||
"""
|
||||
import os
|
||||
from typing import List, Optional, Dict, Tuple
|
||||
|
||||
from PyQt5.QtCore import Qt, QObject, QTimer, QSize, QPropertyAnimation, QEasingCurve
|
||||
from PyQt5.QtGui import QIcon, QPixmap, QPainter, QColor
|
||||
from PyQt5.QtWidgets import QToolButton, QSizePolicy, QFrame, QVBoxLayout
|
||||
import os
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from winehelperqt.qt_compat import (
|
||||
QColor,
|
||||
QEasingCurve,
|
||||
QFrame,
|
||||
QIcon,
|
||||
QObject,
|
||||
QPainter,
|
||||
QPixmap,
|
||||
QPropertyAnimation,
|
||||
QSize,
|
||||
QSizePolicy,
|
||||
Qt,
|
||||
QTimer,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
|
||||
class IconManager(QObject):
|
||||
@@ -34,6 +48,14 @@ class IconManager(QObject):
|
||||
# Длительность анимации fade (мс)
|
||||
FADE_DURATION = 700
|
||||
|
||||
# Стили рамок выделения кнопок (единый источник для GUI и виджетов)
|
||||
SELECTION_FRAME_DEFAULT = (
|
||||
"QFrame { border: 2px solid transparent; border-radius: 8px; padding: 0px; background-color: palette(window); }"
|
||||
)
|
||||
SELECTION_FRAME_ACTIVE = (
|
||||
"QFrame { border: 2px solid #0078d7; border-radius: 8px; padding: 0px; background-color: palette(window); }"
|
||||
)
|
||||
|
||||
def __init__(self, parent: Optional[QObject] = None):
|
||||
"""
|
||||
Инициализирует менеджер иконок.
|
||||
@@ -43,14 +65,16 @@ class IconManager(QObject):
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._icon_animators: Dict[QToolButton, Dict] = {}
|
||||
self._icon_cache: Dict[str, QIcon] = {}
|
||||
|
||||
def create_animated_button(self, text: str,
|
||||
icon_paths: List[str],
|
||||
style_sheet: str,
|
||||
icon_size: int = 32,
|
||||
min_width: int = 250,
|
||||
height: int = 70) -> QToolButton:
|
||||
def create_animated_button(
|
||||
self,
|
||||
text: str,
|
||||
icon_paths: List[str],
|
||||
style_sheet: str,
|
||||
icon_size: int = 32,
|
||||
min_width: int = 250,
|
||||
height: int = 70,
|
||||
) -> QToolButton:
|
||||
"""
|
||||
Создает кнопку с анимацией смены иконок.
|
||||
|
||||
@@ -69,9 +93,9 @@ class IconManager(QObject):
|
||||
btn = QToolButton()
|
||||
btn.setText(text)
|
||||
btn.setStyleSheet(style_sheet)
|
||||
btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
|
||||
btn.setFocusPolicy(Qt.NoFocus)
|
||||
btn.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed)
|
||||
btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon)
|
||||
btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
btn.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Fixed)
|
||||
btn.setMinimumWidth(min_width)
|
||||
btn.setFixedHeight(height)
|
||||
btn.setIconSize(QSize(icon_size, icon_size))
|
||||
@@ -87,14 +111,12 @@ class IconManager(QObject):
|
||||
# Создаём таймер для смены иконок
|
||||
main_timer = QTimer(btn)
|
||||
self._icon_animators[btn] = {
|
||||
'main_timer': main_timer,
|
||||
'icons': existing_paths,
|
||||
'current_index': 0,
|
||||
'animation': None
|
||||
"main_timer": main_timer,
|
||||
"icons": existing_paths,
|
||||
"current_index": 0,
|
||||
"animation": None,
|
||||
}
|
||||
main_timer.timeout.connect(
|
||||
lambda b=btn: self._start_fade_animation(b)
|
||||
)
|
||||
main_timer.timeout.connect(lambda b=btn: self._start_fade_animation(b))
|
||||
main_timer.start(self.DEFAULT_ICON_INTERVAL)
|
||||
else:
|
||||
# Одна иконка без анимации
|
||||
@@ -105,10 +127,9 @@ class IconManager(QObject):
|
||||
|
||||
return btn
|
||||
|
||||
def create_button_with_frame(self, text: str,
|
||||
icon_paths: List[str],
|
||||
style_sheet: str,
|
||||
frame_style: str) -> Tuple[QFrame, QToolButton]:
|
||||
def create_button_with_frame(
|
||||
self, text: str, icon_paths: List[str], style_sheet: str, frame_style: str
|
||||
) -> Tuple[QFrame, QToolButton]:
|
||||
"""
|
||||
Создает кнопку в рамке для выделения.
|
||||
|
||||
@@ -131,6 +152,26 @@ class IconManager(QObject):
|
||||
|
||||
return frame, btn
|
||||
|
||||
@staticmethod
|
||||
def theme_icon(name: str, fallbacks: Optional[List[str]] = None) -> QIcon:
|
||||
"""
|
||||
Возвращает иконку из системной темы значков.
|
||||
|
||||
Args:
|
||||
name: Имя иконки в теме
|
||||
fallbacks: Запасные имена, если основное не найдено
|
||||
|
||||
Returns:
|
||||
Иконка из темы или пустая QIcon, если ничего не найдено
|
||||
"""
|
||||
icon = QIcon.fromTheme(name)
|
||||
if icon.isNull() and fallbacks:
|
||||
for fallback in fallbacks:
|
||||
icon = QIcon.fromTheme(fallback)
|
||||
if not icon.isNull():
|
||||
break
|
||||
return icon
|
||||
|
||||
def create_colorized_icon(self, icon_path: str, color: str) -> QIcon:
|
||||
"""
|
||||
Загружает иконку (SVG) и применяет указанный цвет.
|
||||
@@ -147,38 +188,12 @@ class IconManager(QObject):
|
||||
return QIcon()
|
||||
|
||||
painter = QPainter(target_pixmap)
|
||||
painter.setCompositionMode(QPainter.CompositionMode_SourceIn)
|
||||
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
|
||||
painter.fillRect(target_pixmap.rect(), QColor(color))
|
||||
painter.end()
|
||||
|
||||
return QIcon(target_pixmap)
|
||||
|
||||
def cache_icon(self, key: str, icon: QIcon) -> None:
|
||||
"""
|
||||
Сохраняет иконку в кэш.
|
||||
|
||||
Args:
|
||||
key: Ключ кэша
|
||||
icon: Иконка для сохранения
|
||||
"""
|
||||
self._icon_cache[key] = icon
|
||||
|
||||
def get_cached_icon(self, key: str) -> Optional[QIcon]:
|
||||
"""
|
||||
Получает иконку из кэша.
|
||||
|
||||
Args:
|
||||
key: Ключ кэша
|
||||
|
||||
Returns:
|
||||
Иконка или None
|
||||
"""
|
||||
return self._icon_cache.get(key)
|
||||
|
||||
def clear_icon_cache(self) -> None:
|
||||
"""Очищает кэш иконок."""
|
||||
self._icon_cache.clear()
|
||||
|
||||
def set_selection_style(self, frame: QFrame, selected: bool) -> None:
|
||||
"""
|
||||
Устанавливает стиль выделения для рамки кнопки.
|
||||
@@ -188,15 +203,9 @@ class IconManager(QObject):
|
||||
selected: True для выделения
|
||||
"""
|
||||
if selected:
|
||||
frame.setStyleSheet(
|
||||
"QFrame { border: 2px solid #0078d7; border-radius: 8px; "
|
||||
"padding: 0px; background-color: palette(window); }"
|
||||
)
|
||||
frame.setStyleSheet(self.SELECTION_FRAME_ACTIVE)
|
||||
else:
|
||||
frame.setStyleSheet(
|
||||
"QFrame { border: 2px solid transparent; border-radius: 8px; "
|
||||
"padding: 0px; background-color: palette(window); }"
|
||||
)
|
||||
frame.setStyleSheet(self.SELECTION_FRAME_DEFAULT)
|
||||
|
||||
def stop_button_animation(self, button: QToolButton) -> None:
|
||||
"""
|
||||
@@ -207,16 +216,35 @@ class IconManager(QObject):
|
||||
"""
|
||||
if button in self._icon_animators:
|
||||
anim_data = self._icon_animators.pop(button)
|
||||
if 'main_timer' in anim_data:
|
||||
anim_data['main_timer'].stop()
|
||||
if 'animation' in anim_data and anim_data['animation']:
|
||||
anim_data['animation'].stop()
|
||||
if "main_timer" in anim_data:
|
||||
anim_data["main_timer"].stop()
|
||||
if "animation" in anim_data and anim_data["animation"]:
|
||||
anim_data["animation"].stop()
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Останавливает все анимации и очищает ресурсы."""
|
||||
for button in list(self._icon_animators.keys()):
|
||||
self.stop_button_animation(button)
|
||||
self.clear_icon_cache()
|
||||
def pause_button_animation(self, button: QToolButton) -> None:
|
||||
"""
|
||||
Приостанавливает таймер смены иконок кнопки (состояние анимации сохраняется).
|
||||
|
||||
Используется для кнопок на скрытых вкладках: таймер не должен тикать,
|
||||
пока кнопку не видно. Продолжение — через resume_button_animation.
|
||||
|
||||
Args:
|
||||
button: Кнопка для приостановки анимации
|
||||
"""
|
||||
anim_data = self._icon_animators.get(button)
|
||||
if anim_data and "main_timer" in anim_data:
|
||||
anim_data["main_timer"].stop()
|
||||
|
||||
def resume_button_animation(self, button: QToolButton) -> None:
|
||||
"""
|
||||
Возобновляет таймер смены иконок кнопки после паузы.
|
||||
|
||||
Args:
|
||||
button: Кнопка для возобновления анимации
|
||||
"""
|
||||
anim_data = self._icon_animators.get(button)
|
||||
if anim_data and "main_timer" in anim_data:
|
||||
anim_data["main_timer"].start()
|
||||
|
||||
def _start_fade_animation(self, button: QToolButton) -> None:
|
||||
"""
|
||||
@@ -228,35 +256,35 @@ class IconManager(QObject):
|
||||
if button not in self._icon_animators:
|
||||
return
|
||||
|
||||
# Не тратим ресурсы на анимацию скрытых кнопок (неактивная вкладка, фильтр поиска, свёрнутое окно)
|
||||
if not button.isVisible():
|
||||
return
|
||||
|
||||
anim_data = self._icon_animators[button]
|
||||
|
||||
# Получаем или создаём объект анимации
|
||||
animation = anim_data.get('animation')
|
||||
animation = anim_data.get("animation")
|
||||
if not animation:
|
||||
button.setProperty("fadeProgress", 0.0)
|
||||
animation = QPropertyAnimation(button, b"fadeProgress", self)
|
||||
animation.setDuration(self.FADE_DURATION)
|
||||
animation.setEasingCurve(QEasingCurve.InOutQuad)
|
||||
animation.setEasingCurve(QEasingCurve.Type.InOutQuad)
|
||||
|
||||
animation.valueChanged.connect(
|
||||
lambda value, b=button: self._update_icon_blend(b, value)
|
||||
)
|
||||
animation.finished.connect(
|
||||
lambda b=button: self._on_animation_finished(b)
|
||||
)
|
||||
anim_data['animation'] = animation
|
||||
animation.valueChanged.connect(lambda value, b=button: self._update_icon_blend(b, value))
|
||||
animation.finished.connect(lambda b=button: self._on_animation_finished(b))
|
||||
anim_data["animation"] = animation
|
||||
|
||||
# Останавливаем предыдущую анимацию
|
||||
if animation.state() == QPropertyAnimation.Running:
|
||||
if animation.state() == QPropertyAnimation.State.Running:
|
||||
animation.stop()
|
||||
|
||||
# Определяем текущую и следующую иконки
|
||||
current_icon_path = anim_data['icons'][anim_data['current_index']]
|
||||
next_icon_index = (anim_data['current_index'] + 1) % len(anim_data['icons'])
|
||||
next_icon_path = anim_data['icons'][next_icon_index]
|
||||
current_icon_path = anim_data["icons"][anim_data["current_index"]]
|
||||
next_icon_index = (anim_data["current_index"] + 1) % len(anim_data["icons"])
|
||||
next_icon_path = anim_data["icons"][next_icon_index]
|
||||
|
||||
# Сохраняем QPixmap для использования в функции обновления
|
||||
anim_data['pixmaps'] = (QPixmap(current_icon_path), QPixmap(next_icon_path))
|
||||
anim_data["pixmaps"] = (QPixmap(current_icon_path), QPixmap(next_icon_path))
|
||||
|
||||
# Запускаем анимацию
|
||||
animation.setStartValue(0.0)
|
||||
@@ -267,7 +295,7 @@ class IconManager(QObject):
|
||||
"""Обработчик завершения анимации."""
|
||||
if button in self._icon_animators:
|
||||
anim_data = self._icon_animators[button]
|
||||
anim_data['current_index'] = (anim_data['current_index'] + 1) % len(anim_data['icons'])
|
||||
anim_data["current_index"] = (anim_data["current_index"] + 1) % len(anim_data["icons"])
|
||||
|
||||
def _update_icon_blend(self, button: QToolButton, progress: float) -> None:
|
||||
"""
|
||||
@@ -278,10 +306,10 @@ class IconManager(QObject):
|
||||
progress: Прогресс от 0.0 до 1.0
|
||||
"""
|
||||
anim_data = self._icon_animators.get(button)
|
||||
if not anim_data or 'pixmaps' not in anim_data:
|
||||
if not anim_data or "pixmaps" not in anim_data:
|
||||
return
|
||||
|
||||
old_pixmap, new_pixmap = anim_data['pixmaps']
|
||||
old_pixmap, new_pixmap = anim_data["pixmaps"]
|
||||
|
||||
# На последнем кадре устанавливаем новую иконку
|
||||
if progress >= 1.0:
|
||||
@@ -290,10 +318,10 @@ class IconManager(QObject):
|
||||
|
||||
# Создаём холст для отрисовки
|
||||
canvas = QPixmap(button.iconSize())
|
||||
canvas.fill(Qt.transparent)
|
||||
canvas.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(canvas)
|
||||
painter.setRenderHint(QPainter.Antialiasing, True)
|
||||
painter.setRenderHint(QPainter.SmoothPixmapTransform, True)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True)
|
||||
|
||||
# Плавно скрываем старую иконку
|
||||
painter.setOpacity(1.0 - progress)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Контроллер правой информационной панели главного окна WineHelper.
|
||||
|
||||
Единая точка координации панели: заголовок и описание принадлежат контроллеру,
|
||||
вкладки добавляют свои секции кнопок через add_section() и управляют
|
||||
видимостью через явные методы вместо прямого доступа к чужим виджетам.
|
||||
"""
|
||||
|
||||
from winehelperqt.qt_compat import (
|
||||
QFrame,
|
||||
QLabel,
|
||||
QPixmap,
|
||||
QSizePolicy,
|
||||
Qt,
|
||||
QTextBrowser,
|
||||
QVBoxLayout,
|
||||
)
|
||||
from winehelperqt.utils.font_utils import app_font
|
||||
|
||||
|
||||
class InfoPanelController(QFrame):
|
||||
"""
|
||||
Правая информационная панель главного окна.
|
||||
|
||||
Содержит заголовок (script_title) и описание (script_description);
|
||||
секции кнопок вкладок добавляются через add_section() в порядке:
|
||||
заголовок, описание, путь к установочному файлу, действия установщика
|
||||
(добавляются главным окном), действия приложения, spacer, глобальные
|
||||
действия «Установленные» (добавляются InstalledTab).
|
||||
"""
|
||||
|
||||
# Индексы строк layout панели — используются в reset_stretch().
|
||||
# Зависят от порядка addWidget/add_section выше; при изменении порядка
|
||||
# добавления обновить и константы.
|
||||
INDEX_TITLE = 0
|
||||
INDEX_DESCRIPTION = 1
|
||||
INDEX_MANUAL_INSTALL_PATH = 2
|
||||
INDEX_INSTALL_ACTIONS = 3
|
||||
INDEX_INSTALLED_ACTIONS = 4
|
||||
INDEX_INSTALLED_SPACER = 5
|
||||
INDEX_INSTALLED_GLOBAL_ACTIONS = 6
|
||||
|
||||
def __init__(self, parent=None):
|
||||
"""
|
||||
Инициализирует панель: рамка, шрифт, заголовок и описание.
|
||||
|
||||
Args:
|
||||
parent: Родительский виджет (главное окно)
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
self.setMinimumWidth(400) # Размер инф. панели
|
||||
self.setFont(app_font(10)) # Размер шрифта в инф. панели
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Заголовок
|
||||
self.script_title = QLabel("Выберите программу")
|
||||
self.script_title.setFont(app_font(12, bold=True))
|
||||
self.script_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
# Заголовок не растягивается по вертикали, чтобы кнопка "Запустить" поднялась
|
||||
self.script_title.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
||||
# Изначально скрыт: появляется только при выборе программы
|
||||
self.script_title.setVisible(False)
|
||||
layout.addWidget(self.script_title)
|
||||
|
||||
# Описание
|
||||
self.script_description = QTextBrowser()
|
||||
self.script_description.setReadOnly(True)
|
||||
self.script_description.setOpenExternalLinks(True)
|
||||
layout.addWidget(self.script_description)
|
||||
|
||||
def add_section(self, widget):
|
||||
"""Добавляет секцию кнопок вкладки вниз панели."""
|
||||
self.layout().addWidget(widget)
|
||||
|
||||
def set_visible(self, visible):
|
||||
"""Показывает или скрывает панель целиком."""
|
||||
self.setVisible(visible)
|
||||
|
||||
def set_title(self, text):
|
||||
"""Устанавливает текст заголовка панели."""
|
||||
self.script_title.setText(text)
|
||||
|
||||
def set_title_icon(self, pixmap: QPixmap):
|
||||
"""Устанавливает иконку заголовка панели (пустой QPixmap — без иконки)."""
|
||||
self.script_title.setPixmap(pixmap)
|
||||
|
||||
def set_title_visible(self, visible):
|
||||
"""Показывает или скрывает заголовок панели."""
|
||||
self.script_title.setVisible(visible)
|
||||
|
||||
def set_description(self, html_content):
|
||||
"""Устанавливает HTML-содержимое описания панели."""
|
||||
self.script_description.setHtml(html_content)
|
||||
|
||||
def set_description_visible(self, visible):
|
||||
"""Показывает или скрывает описание панели."""
|
||||
self.script_description.setVisible(visible)
|
||||
|
||||
def reset_stretch(self):
|
||||
"""Сбрасывает растяжение к состоянию по умолчанию: растягивается описание, а не секции с кнопками."""
|
||||
self.layout().setStretch(self.INDEX_DESCRIPTION, 1)
|
||||
self.layout().setStretch(self.INDEX_INSTALLED_ACTIONS, 0)
|
||||
@@ -0,0 +1,425 @@
|
||||
"""
|
||||
Вкладки установки главного окна WineHelper.
|
||||
|
||||
InstallTab — вкладки «Автоматическая установка» и «Ручная установка»
|
||||
(сетка кнопок скриптов установки с поиском).
|
||||
ExternalInstallTab — вкладка «Сторонняя установка» (установка из собственного
|
||||
скрипта пользователя).
|
||||
"""
|
||||
|
||||
import html
|
||||
import os
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.qt_compat import (
|
||||
QCheckBox,
|
||||
QFileDialog,
|
||||
QFrame,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPixmap,
|
||||
QPushButton,
|
||||
Qt,
|
||||
QTimer,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from winehelperqt.utils.environment import EnvironmentConfig
|
||||
from winehelperqt.utils.grid_utils import GridUtils
|
||||
from winehelperqt.widgets.tab_names import TabNames
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class InstallTab(QWidget):
|
||||
"""Вкладка установки программ из скриптов (автоматической или ручной)."""
|
||||
|
||||
def __init__(self, gui, tab_type, script_folders, search_placeholder):
|
||||
"""
|
||||
Инициализирует вкладку установки.
|
||||
|
||||
Args:
|
||||
gui: Главное окно WineHelperGUI (доступ к общим сервисам и колбэкам)
|
||||
tab_type: Тип вкладки ('auto' или 'manual')
|
||||
script_folders: Список папок со скриптами ('autoinstall', 'manualinstall')
|
||||
search_placeholder: Подсказка для поля поиска
|
||||
"""
|
||||
super().__init__(gui)
|
||||
self.gui = gui
|
||||
self.tab_type = tab_type
|
||||
|
||||
# Локальные ссылки на общие сервисы главного окна
|
||||
self.script_manager = gui.script_manager
|
||||
self.icon_manager = gui.icon_manager
|
||||
|
||||
# Состояние вкладки
|
||||
self.scripts = [] # Имена скриптов вкладки
|
||||
self.buttons = [] # Кнопки скриптов в сетке
|
||||
self.test_buttons = [] # Кнопки тестовых скриптов (только для 'auto')
|
||||
self.test_checkbox = None # Чекбокс «Показать тестовые версии» (только для 'auto')
|
||||
|
||||
# Содержимое вкладки: поиск + сетка кнопок (общий помощник главного окна)
|
||||
content = gui.create_searchable_grid_tab(search_placeholder, self.filter_buttons, with_description_search=True)
|
||||
self.grid_layout = content.grid_layout
|
||||
self.search_edit = content.search_edit
|
||||
self.scroll_area = content.scroll_area
|
||||
self.search_desc_checkbox = content.description_checkbox
|
||||
self.empty_label = content.empty_label
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(5)
|
||||
layout.addWidget(content.widget)
|
||||
|
||||
self._populate_scripts(script_folders)
|
||||
|
||||
# Чекбокс для тестовых версий — только на вкладке автоматической установки
|
||||
if self.tab_type == "auto":
|
||||
self.test_checkbox = QCheckBox("Показать тестовые версии")
|
||||
self.test_checkbox.setToolTip("Показать/скрыть экспериментальные скрипты установки")
|
||||
self.test_checkbox.stateChanged.connect(self.update_auto_install_list)
|
||||
layout.addWidget(self.test_checkbox)
|
||||
|
||||
# Начальное состояние метки пустого состояния (на случай пустого списка скриптов)
|
||||
self.filter_buttons()
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Возобновляет анимации иконок при показе вкладки."""
|
||||
super().showEvent(event)
|
||||
self._set_animations_running(True)
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""Останавливает таймеры анимаций кнопок, пока вкладка скрыта."""
|
||||
super().hideEvent(event)
|
||||
self._set_animations_running(False)
|
||||
|
||||
def _set_animations_running(self, running):
|
||||
"""Запускает или приостанавливает таймеры смены иконок всех кнопок вкладки."""
|
||||
for btn in self.buttons:
|
||||
if running:
|
||||
self.icon_manager.resume_button_animation(btn)
|
||||
else:
|
||||
self.icon_manager.pause_button_animation(btn)
|
||||
|
||||
def _populate_scripts(self, script_folders):
|
||||
"""Читает списки скриптов из папок и заполняет сетку кнопками."""
|
||||
for folder in script_folders:
|
||||
script_path = os.path.join(EnvironmentConfig.DATA_PATH, folder)
|
||||
if os.path.isdir(script_path):
|
||||
try:
|
||||
folder_scripts = sorted(os.listdir(script_path))
|
||||
self.scripts.extend(folder_scripts)
|
||||
self._populate_install_grid(folder_scripts, folder, self.buttons)
|
||||
except OSError as e:
|
||||
logger.error("Не удалось прочитать директорию %s: %s", script_path, e)
|
||||
|
||||
def _populate_install_grid(self, scripts_list, script_folder, button_list, start_index=0):
|
||||
"""
|
||||
Заполняет сетку вкладки кнопками установщиков.
|
||||
Кнопки создаются только для скриптов, в которых найдена переменная PROG_NAME.
|
||||
|
||||
:param scripts_list: Список имен скриптов.
|
||||
:param script_folder: Имя папки со скриптами ('autoinstall', 'manualinstall' или 'testinstall').
|
||||
:param button_list: Список для хранения созданных кнопок.
|
||||
:param start_index: Начальный индекс для расчета позиции в сетке.
|
||||
"""
|
||||
for script in scripts_list:
|
||||
script_path = os.path.join(EnvironmentConfig.DATA_PATH, script_folder, script)
|
||||
script_info = self.script_manager.get_script_info(script_path)
|
||||
|
||||
# Создаем кнопку, только если для скрипта указано имя программы
|
||||
if not script_info:
|
||||
continue
|
||||
|
||||
# Получаем пути к иконкам
|
||||
icon_paths = [
|
||||
os.path.join(EnvironmentConfig.DATA_PATH, "image", f"{name}.png") for name in script_info.icons
|
||||
]
|
||||
|
||||
# Выбираем стиль в зависимости от папки
|
||||
if script_folder == "testinstall":
|
||||
style_sheet = self.gui.EXPERIMENTAL_APP_BUTTON_STYLE
|
||||
frame_style = self.gui.EXPERIMENTAL_FRAME_STYLE
|
||||
else:
|
||||
style_sheet = self.gui.APP_BUTTON_STYLE
|
||||
frame_style = self.gui.SELECTION_FRAME_DEFAULT
|
||||
|
||||
frame, btn = self.icon_manager.create_button_with_frame(
|
||||
script_info.name, icon_paths, style_sheet, frame_style
|
||||
)
|
||||
|
||||
btn.clicked.connect(lambda checked, s=script, b=btn: self.show_script_info(s, b))
|
||||
# Сохраняем расширенный текст для поиска (имя + описание)
|
||||
btn.setProperty("search_text", f"{script_info.name} {script_info.description}")
|
||||
row, column = divmod(start_index + len(button_list), 2)
|
||||
self.grid_layout.addWidget(frame, row, column)
|
||||
button_list.append(btn)
|
||||
|
||||
def filter_buttons(self):
|
||||
"""Фильтрует кнопки вкладки по тексту поиска."""
|
||||
# При включённом чекбоксе ищем по расширенному тексту (имя + описание)
|
||||
if self.search_desc_checkbox is not None and self.search_desc_checkbox.isChecked():
|
||||
text_getter = lambda btn: btn.property("search_text") or btn.text() # noqa: E731
|
||||
else:
|
||||
text_getter = None
|
||||
visible = self.gui.filter_buttons_in_grid(self.search_edit.text(), self.buttons, self.grid_layout, text_getter)
|
||||
GridUtils.update_empty_state(self.empty_label, len(visible), self.search_edit.text(), "Список программ пуст")
|
||||
|
||||
def show_script_info(self, script_name, button_widget):
|
||||
"""Показывает информацию о выбранном скрипте"""
|
||||
gui = self.gui
|
||||
gui.set_active_button(button_widget)
|
||||
gui.current_script = script_name
|
||||
|
||||
# Определяем виджеты и действия в зависимости от типа вкладки
|
||||
if self.tab_type == "auto":
|
||||
script_path = os.path.join(EnvironmentConfig.DATA_PATH, "autoinstall", script_name)
|
||||
if not os.path.exists(script_path): # Проверяем в testinstall, если не нашли в autoinstall
|
||||
script_path = os.path.join(EnvironmentConfig.DATA_PATH, "testinstall", script_name)
|
||||
gui.set_manual_install_path_visible(False)
|
||||
else:
|
||||
script_path = os.path.join(EnvironmentConfig.DATA_PATH, "manualinstall", script_name)
|
||||
gui.set_manual_install_path_visible(True)
|
||||
|
||||
# Полный путь к скрипту: установка использует его, не угадывая папку повторно
|
||||
gui.current_script_path = script_path
|
||||
|
||||
# Если в поиске был текст, очищаем его и перерисовываем список.
|
||||
# Это предотвращает "прыжок", если список не был отфильтрован.
|
||||
if self.search_edit.text():
|
||||
self.search_edit.blockSignals(True)
|
||||
self.search_edit.clear()
|
||||
self.search_edit.blockSignals(False)
|
||||
self.filter_buttons()
|
||||
|
||||
frame = button_widget.parent()
|
||||
if isinstance(frame, QFrame):
|
||||
QTimer.singleShot(0, lambda: self.scroll_area.ensureWidgetVisible(frame))
|
||||
|
||||
# Обновляем информацию в правой панели
|
||||
script_info = self.script_manager.get_script_info(script_path)
|
||||
if not script_info:
|
||||
return
|
||||
|
||||
display_name = script_info.name if script_info.name else script_name
|
||||
gui.current_display_name = display_name
|
||||
|
||||
if script_info.icons:
|
||||
# Для заголовка используем первую иконку из списка
|
||||
icon_path = os.path.join(EnvironmentConfig.DATA_PATH, "image", f"{script_info.icons[0]}.png")
|
||||
if os.path.exists(icon_path):
|
||||
gui.info_panel.set_title_icon(QPixmap(icon_path).scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio))
|
||||
else:
|
||||
gui.info_panel.set_title_icon(QPixmap())
|
||||
else:
|
||||
gui.info_panel.set_title_icon(QPixmap())
|
||||
|
||||
gui.info_panel.set_title(display_name)
|
||||
gui.info_panel.set_title_visible(True)
|
||||
|
||||
# Экранируем данные из скрипта установки: они вставляются в HTML-виджет
|
||||
html_description = f"<p>{html.escape(script_info.description)}</p>"
|
||||
if script_info.url:
|
||||
escaped_url = html.escape(script_info.url)
|
||||
html_description += f'<p><b>Официальный сайт:</b> <a href="{escaped_url}">{escaped_url}</a></p>'
|
||||
|
||||
gui.info_panel.set_description(html_description)
|
||||
gui.info_panel.set_description_visible(True)
|
||||
gui.set_installer_action_visible(True)
|
||||
# Скрываем секцию действий вкладки «Установленные» (ею управляет сама вкладка)
|
||||
gui.installed_tab.show_default_panel(False)
|
||||
gui.install_button.setText(f"Установить «{display_name}»")
|
||||
|
||||
def update_auto_install_list(self):
|
||||
"""Обновляет список на вкладке 'Автоматическая установка' при изменении чекбокса."""
|
||||
is_checked = self.test_checkbox.isChecked()
|
||||
|
||||
# Если нужно показать тестовые версии и они еще не добавлены
|
||||
if is_checked and not self.test_buttons:
|
||||
self._add_test_scripts_to_ui()
|
||||
|
||||
# Если нужно скрыть тестовые версии и они были добавлены
|
||||
elif not is_checked and self.test_buttons:
|
||||
self._remove_test_scripts_from_ui()
|
||||
|
||||
# В любом случае применяем фильтр, чтобы скрыть/показать кнопки в соответствии с поиском
|
||||
if self.test_checkbox.isChecked():
|
||||
self.filter_buttons()
|
||||
|
||||
def _add_test_scripts_to_ui(self):
|
||||
"""Добавляет тестовые скрипты в UI."""
|
||||
test_script_folder = "testinstall"
|
||||
script_path = os.path.join(EnvironmentConfig.DATA_PATH, test_script_folder)
|
||||
if not os.path.isdir(script_path):
|
||||
return
|
||||
|
||||
try:
|
||||
folder_scripts = sorted(os.listdir(script_path))
|
||||
# Запоминаем, какие кнопки являются тестовыми
|
||||
new_test_buttons = []
|
||||
self._populate_install_grid(
|
||||
folder_scripts, test_script_folder, new_test_buttons, start_index=len(self.buttons)
|
||||
)
|
||||
self.test_buttons = new_test_buttons
|
||||
self.buttons.extend(new_test_buttons)
|
||||
self.scripts.extend(folder_scripts)
|
||||
|
||||
# Применяем фильтр и прокручиваем к первому новому элементу
|
||||
self.filter_buttons()
|
||||
if new_test_buttons:
|
||||
first_new_button = new_test_buttons[0]
|
||||
frame = first_new_button.parent()
|
||||
if isinstance(frame, QFrame):
|
||||
# Даем время на отрисовку перед прокруткой
|
||||
QTimer.singleShot(100, lambda: self.scroll_area.ensureWidgetVisible(frame, 50, 50))
|
||||
|
||||
except OSError as e:
|
||||
logger.error("Не удалось прочитать директорию %s: %s", script_path, e)
|
||||
|
||||
def _remove_test_scripts_from_ui(self):
|
||||
"""Удаляет тестовые скрипты из UI."""
|
||||
# Если текущая активная кнопка находится среди удаляемых, сбрасываем ее
|
||||
if self.gui.selected_app_button in self.test_buttons:
|
||||
self.gui.reset_info_panel_to_default(TabNames.AUTO_INSTALL)
|
||||
self.gui.selected_app_button = None
|
||||
|
||||
# Останавливаем анимацию и удаляем виджеты тестовых кнопок
|
||||
for btn in self.test_buttons:
|
||||
self.icon_manager.stop_button_animation(btn)
|
||||
|
||||
# Удаляем кнопку из основного списка
|
||||
if btn in self.buttons:
|
||||
self.buttons.remove(btn)
|
||||
|
||||
# Удаляем фрейм кнопки из layout
|
||||
frame = btn.parent()
|
||||
if frame:
|
||||
frame.deleteLater()
|
||||
|
||||
# Очищаем список тестовых кнопок
|
||||
self.test_buttons.clear()
|
||||
# Обновляем список скриптов
|
||||
self.scripts = [
|
||||
s for s in self.scripts if not os.path.exists(os.path.join(EnvironmentConfig.DATA_PATH, "testinstall", s))
|
||||
]
|
||||
|
||||
|
||||
class ExternalInstallTab(QWidget):
|
||||
"""Вкладка «Сторонняя установка» — установка из собственного скрипта пользователя."""
|
||||
|
||||
def __init__(self, gui):
|
||||
"""
|
||||
Инициализирует вкладку сторонней установки.
|
||||
|
||||
Args:
|
||||
gui: Главное окно WineHelperGUI (доступ к общим сервисам и колбэкам)
|
||||
"""
|
||||
super().__init__(gui)
|
||||
self.gui = gui
|
||||
|
||||
# Локальные ссылки на общие сервисы главного окна
|
||||
self.script_manager = gui.script_manager
|
||||
self.install_runner = gui.install_runner
|
||||
self.winehelper_path = gui.winehelper_path
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(10, 10, 10, 10)
|
||||
layout.setSpacing(10)
|
||||
|
||||
group_box = QGroupBox("Установка из стороннего скрипта")
|
||||
group_layout = QVBoxLayout(group_box)
|
||||
|
||||
path_layout = QHBoxLayout()
|
||||
self.external_script_path_edit = QLineEdit()
|
||||
self.external_script_path_edit.setPlaceholderText("Укажите путь к скрипту установки...")
|
||||
path_layout.addWidget(self.external_script_path_edit)
|
||||
browse_button = QPushButton("Обзор…")
|
||||
browse_button.setToolTip("Выбрать файл скрипта установки.")
|
||||
browse_button.clicked.connect(self.browse_external_script)
|
||||
path_layout.addWidget(browse_button)
|
||||
group_layout.addLayout(path_layout)
|
||||
|
||||
file_layout = QHBoxLayout()
|
||||
self.external_install_file_edit = QLineEdit()
|
||||
self.external_install_file_edit.setPlaceholderText("Установочный файл (необязательно)...")
|
||||
self.external_install_file_edit.setToolTip(
|
||||
"Путь к дистрибутиву (.exe, .msi), если скрипт рассчитан на установку из локального файла."
|
||||
)
|
||||
file_layout.addWidget(self.external_install_file_edit)
|
||||
browse_file_button = QPushButton("Обзор…")
|
||||
browse_file_button.setToolTip("Выбрать установочный файл.")
|
||||
browse_file_button.clicked.connect(self.browse_external_install_file)
|
||||
file_layout.addWidget(browse_file_button)
|
||||
group_layout.addLayout(file_layout)
|
||||
|
||||
self.external_clear_pfx_checkbox = QCheckBox("Не использовать готовый префикс")
|
||||
self.external_clear_pfx_checkbox.setToolTip("Установка без готового префикса-шаблона (флаг --clear-pfx).")
|
||||
group_layout.addWidget(self.external_clear_pfx_checkbox)
|
||||
|
||||
install_button = QPushButton("Установить")
|
||||
install_button.setMinimumHeight(32)
|
||||
install_button.clicked.connect(self.install_external_script)
|
||||
group_layout.addWidget(install_button)
|
||||
|
||||
layout.addWidget(group_box)
|
||||
layout.addStretch(1)
|
||||
|
||||
def browse_external_script(self):
|
||||
"""Открывает диалог выбора стороннего скрипта установки."""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Выберите скрипт установки", os.path.expanduser("~"), "Все файлы (*)"
|
||||
)
|
||||
if file_path:
|
||||
self.external_script_path_edit.setText(file_path)
|
||||
|
||||
def browse_external_install_file(self):
|
||||
"""Открывает диалог выбора установочного файла для стороннего скрипта."""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Выберите установочный файл",
|
||||
os.path.expanduser("~"),
|
||||
"Установочные файлы (*.exe *.msi);;Все файлы (*)",
|
||||
)
|
||||
if file_path:
|
||||
self.external_install_file_edit.setText(file_path)
|
||||
|
||||
def install_external_script(self):
|
||||
"""Запускает установку программы из стороннего скрипта."""
|
||||
script_path = self.external_script_path_edit.text().strip()
|
||||
if not script_path:
|
||||
QMessageBox.warning(self, "Ошибка", "Укажите путь к скрипту установки")
|
||||
return
|
||||
if not os.path.isfile(script_path):
|
||||
QMessageBox.warning(self, "Ошибка", f"Скрипт установки не найден:\n{script_path}")
|
||||
return
|
||||
|
||||
install_file = self.external_install_file_edit.text().strip()
|
||||
if install_file and not os.path.isfile(install_file):
|
||||
QMessageBox.warning(self, "Ошибка", f"Установочный файл не найден:\n{install_file}")
|
||||
return
|
||||
|
||||
if not self.gui.ensure_license_accepted():
|
||||
return # Пользователь отклонил лицензию
|
||||
|
||||
# Имя для заголовка диалога: из скрипта, иначе имя файла
|
||||
script_info = self.script_manager.get_script_info(script_path)
|
||||
if script_info and script_info.name:
|
||||
title_name = script_info.name
|
||||
else:
|
||||
title_name = os.path.basename(script_path)
|
||||
|
||||
self.gui.prefixes_before_install = self.gui.prefix_tab.get_current_prefixes()
|
||||
self.install_runner.create_dialog(title_name)
|
||||
clear_pfx = self.external_clear_pfx_checkbox.isChecked()
|
||||
QTimer.singleShot(
|
||||
100,
|
||||
lambda: self.install_runner.start(
|
||||
self.winehelper_path,
|
||||
script_path,
|
||||
install_file=install_file or None,
|
||||
external=True,
|
||||
clear_pfx=clear_pfx,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,813 @@
|
||||
"""
|
||||
Вкладка «Установленные» главного окна WineHelper.
|
||||
|
||||
Содержит сетку установленных приложений, запуск/остановку приложений,
|
||||
создание логов, резервное копирование/восстановление и удаление префиксов,
|
||||
а также секцию действий с приложением в правой информационной панели.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from functools import partial
|
||||
|
||||
from winehelperqt.dialogs.create_log import CreateLogDialog
|
||||
from winehelperqt.dialogs.settings import AppAdditionalSettingsDialog
|
||||
from winehelperqt.dialogs.winedebug_base import DEFAULT_WINEDEBUG
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.qt_compat import (
|
||||
QDialog,
|
||||
QFileDialog,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPixmap,
|
||||
QProcess,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QTimer,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from winehelperqt.services.desktop_file_manager import DesktopFileManager
|
||||
from winehelperqt.utils.desktop_entry import DesktopEntry
|
||||
from winehelperqt.utils.dialog_utils import DialogUtils
|
||||
from winehelperqt.utils.environment import EnvironmentConfig
|
||||
from winehelperqt.utils.grid_utils import GridUtils
|
||||
from winehelperqt.utils.process_utils import ProcessUtils
|
||||
from winehelperqt.widgets.tab_names import TabNames
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class InstalledTab(QWidget):
|
||||
"""Вкладка «Установленные»."""
|
||||
|
||||
def __init__(self, gui):
|
||||
"""
|
||||
Инициализирует вкладку установленных приложений.
|
||||
|
||||
Args:
|
||||
gui: Главное окно WineHelperGUI (доступ к общим сервисам и колбэкам)
|
||||
"""
|
||||
super().__init__(gui)
|
||||
self.gui = gui
|
||||
|
||||
# Локальные ссылки на общие сервисы главного окна
|
||||
self.desktop_file_manager = gui.desktop_file_manager
|
||||
self.app_launcher = gui.app_launcher
|
||||
self.prefix_manager = gui.prefix_manager
|
||||
self.icon_manager = gui.icon_manager
|
||||
self.install_runner = gui.install_runner
|
||||
self.command_runner = gui.command_runner
|
||||
self.winehelper_path = gui.winehelper_path
|
||||
|
||||
# Состояние вкладки
|
||||
self.current_selected_app = None # Данные выбранного установленного приложения
|
||||
self.installed_app_buttons = [] # Список кнопок установленных приложений
|
||||
self._is_stopping = False # Флаг ожидания остановки приложения
|
||||
self._debug_launch_active = False # Флаг запуска с созданием лога
|
||||
self._installed_apps_signature = None # Кэш сигнатуры сетки установленных (задача кэша)
|
||||
|
||||
# Содержимое вкладки: поиск + сетка кнопок (общий помощник главного окна)
|
||||
content = gui.create_searchable_grid_tab(
|
||||
"Поиск установленной программы...", self.filter_installed_buttons, add_stretch=True
|
||||
)
|
||||
self.installed_scroll_layout = content.grid_layout
|
||||
self.installed_search_edit = content.search_edit
|
||||
self.installed_scroll_area = content.scroll_area
|
||||
self.empty_label = content.empty_label
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(content.widget)
|
||||
|
||||
# Секция действий с выбранным приложением в правой панели главного окна
|
||||
self._create_installed_actions_section()
|
||||
self.installed_action_widget.setVisible(False)
|
||||
self.installed_global_action_widget.setVisible(False)
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Возобновляет анимации иконок при показе вкладки."""
|
||||
super().showEvent(event)
|
||||
self._set_animations_running(True)
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""Останавливает таймеры анимаций кнопок, пока вкладка скрыта."""
|
||||
super().hideEvent(event)
|
||||
self._set_animations_running(False)
|
||||
|
||||
def _set_animations_running(self, running):
|
||||
"""Запускает или приостанавливает таймеры смены иконок всех кнопок вкладки."""
|
||||
for btn in self.installed_app_buttons:
|
||||
if running:
|
||||
self.icon_manager.resume_button_animation(btn)
|
||||
else:
|
||||
self.icon_manager.pause_button_animation(btn)
|
||||
|
||||
def _create_installed_actions_section(self):
|
||||
"""Создаёт секцию с кнопками действий для установленных приложений."""
|
||||
# Виджет для действий с установленным приложением
|
||||
self.installed_action_widget = QWidget()
|
||||
installed_action_layout = QVBoxLayout()
|
||||
installed_action_layout.setContentsMargins(0, 0, 0, 0)
|
||||
installed_action_layout.setSpacing(0)
|
||||
|
||||
# Верхний ряд кнопок
|
||||
top_buttons_layout = QHBoxLayout()
|
||||
top_buttons_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.run_button = QPushButton("Запустить")
|
||||
self.run_button.setStyleSheet(self.gui.RUN_BUTTON_STYLE)
|
||||
self.run_button.clicked.connect(self.toggle_run_stop_app)
|
||||
top_buttons_layout.addWidget(self.run_button)
|
||||
installed_action_layout.addLayout(top_buttons_layout)
|
||||
|
||||
self.log_status_label = QLabel("Идёт создание и сохранение лога…")
|
||||
self.log_status_label.setVisible(False)
|
||||
self.log_status_label.setStyleSheet("color: #ff9800; font-weight: bold;")
|
||||
installed_action_layout.addWidget(self.log_status_label)
|
||||
|
||||
self.log_progress_bar = QProgressBar()
|
||||
self.log_progress_bar.setVisible(False)
|
||||
self.log_progress_bar.setRange(0, 0)
|
||||
self.log_progress_bar.setFixedHeight(4)
|
||||
self.log_progress_bar.setStyleSheet(
|
||||
"QProgressBar {border: none; border-radius: 2px; "
|
||||
"background-color: #e0e0e0;}"
|
||||
"QProgressBar::chunk {border-radius: 2px; background-color: #ff9800;}"
|
||||
)
|
||||
installed_action_layout.addWidget(self.log_progress_bar)
|
||||
|
||||
self.installed_action_widget.setLayout(installed_action_layout)
|
||||
self.gui.info_panel.add_section(self.installed_action_widget)
|
||||
|
||||
# Фиксированный промежуток между кнопкой "Запустить" и остальными кнопками.
|
||||
# Компенсирует подъём кнопки "Запустить", чтобы остальные кнопки
|
||||
# остались примерно на прежнем месте.
|
||||
self.installed_buttons_spacer = QWidget()
|
||||
self.installed_buttons_spacer.setFixedHeight(120)
|
||||
self.installed_buttons_spacer.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
self.installed_buttons_spacer.setVisible(False)
|
||||
self.gui.info_panel.add_section(self.installed_buttons_spacer)
|
||||
|
||||
# Глобальные действия для установленных приложения
|
||||
self.installed_global_action_widget = QWidget()
|
||||
installed_global_layout = QVBoxLayout()
|
||||
installed_global_layout.setContentsMargins(0, 10, 0, 0)
|
||||
|
||||
self.app_additional_settings_button = QPushButton("Дополнительные настройки приложения")
|
||||
settings_icon = self.icon_manager.theme_icon("configure-shortcuts", fallbacks=["configure"])
|
||||
self.app_additional_settings_button.setIcon(settings_icon)
|
||||
self.app_additional_settings_button.clicked.connect(self.open_app_additional_settings)
|
||||
installed_global_layout.addWidget(self.app_additional_settings_button)
|
||||
|
||||
self.create_log_button = QPushButton("Создать лог запуска программы")
|
||||
self.create_log_button.setIcon(self.icon_manager.theme_icon("view-list-text"))
|
||||
self.create_log_button.clicked.connect(self.run_installed_app_with_debug)
|
||||
installed_global_layout.addWidget(self.create_log_button)
|
||||
|
||||
self.backup_button = QPushButton("Создать резервную копию префикса")
|
||||
self.backup_button.setIcon(self.icon_manager.theme_icon("document-save"))
|
||||
self.backup_button.clicked.connect(self.backup_prefix_for_app)
|
||||
installed_global_layout.addWidget(self.backup_button)
|
||||
|
||||
self.open_log_dir_button = QPushButton("Открыть папку с логом/резервной копией префикса")
|
||||
self.open_log_dir_button.setIcon(self.icon_manager.theme_icon("folder-open"))
|
||||
self.open_log_dir_button.clicked.connect(self.open_log_directory)
|
||||
self.open_log_dir_button.setVisible(False)
|
||||
installed_global_layout.addWidget(self.open_log_dir_button)
|
||||
|
||||
self.uninstall_button = QPushButton("Удалить префикс")
|
||||
self.uninstall_button.setIcon(self.icon_manager.theme_icon("user-trash"))
|
||||
self.uninstall_button.clicked.connect(self.uninstall_app)
|
||||
installed_global_layout.addWidget(self.uninstall_button)
|
||||
|
||||
self.restore_prefix_button_panel = QPushButton("Восстановить префикс из резервной копии")
|
||||
self.restore_prefix_button_panel.setIcon(self.icon_manager.theme_icon("document-revert"))
|
||||
self.restore_prefix_button_panel.clicked.connect(self.restore_prefix)
|
||||
installed_global_layout.addWidget(self.restore_prefix_button_panel)
|
||||
|
||||
self.installed_global_action_widget.setLayout(installed_global_layout)
|
||||
self.gui.info_panel.add_section(self.installed_global_action_widget)
|
||||
|
||||
def show_default_panel(self, show_global_actions=False):
|
||||
"""Сбрасывает секцию действий установленного приложения в состояние по умолчанию."""
|
||||
self.installed_action_widget.setVisible(False)
|
||||
self.installed_buttons_spacer.setVisible(False)
|
||||
self.installed_global_action_widget.setVisible(show_global_actions)
|
||||
self.app_additional_settings_button.setVisible(False)
|
||||
self.backup_button.setVisible(False)
|
||||
self.create_log_button.setVisible(False)
|
||||
self.open_log_dir_button.setVisible(False)
|
||||
self.uninstall_button.setVisible(False)
|
||||
self.current_selected_app = None
|
||||
|
||||
def show_app_panel(self):
|
||||
"""Показывает секцию действий для выбранного установленного приложения."""
|
||||
self.installed_action_widget.setVisible(True)
|
||||
self.installed_buttons_spacer.setVisible(True)
|
||||
self.installed_global_action_widget.setVisible(True)
|
||||
self.app_additional_settings_button.setVisible(True)
|
||||
self.backup_button.setVisible(True)
|
||||
self.create_log_button.setVisible(True)
|
||||
self.open_log_dir_button.setVisible(True)
|
||||
self.uninstall_button.setVisible(True)
|
||||
self.update_open_log_dir_button_state()
|
||||
|
||||
def update_installed_apps(self):
|
||||
"""Обновляет список установленных приложений в виде кнопок"""
|
||||
# Получаем информацию о приложениях из сервиса
|
||||
apps = self.desktop_file_manager.get_installed_apps_with_info()
|
||||
|
||||
# Кэш: сетка пересобирается только при изменении сигнатуры —
|
||||
# набора .desktop файлов с их mtime и фактом существования .exe.
|
||||
# Сценарии установки/удаления/восстановления/создания ярлыка меняют
|
||||
# набор файлов, их содержимое или .exe — сигнатура это ловит.
|
||||
# Зелёная рамка запущенных приложений и панель действий обновляются
|
||||
# отдельными методами и от пересбора сетки не зависят.
|
||||
try:
|
||||
signature = tuple(
|
||||
(app["path"], os.path.getmtime(app["path"]), DesktopFileManager.get_exe_path_from_exec(app["exec"]))
|
||||
for app in apps
|
||||
)
|
||||
except OSError:
|
||||
signature = None # Гонка с удалением файла — пересобираем
|
||||
if signature is not None and signature == self._installed_apps_signature:
|
||||
return
|
||||
self._installed_apps_signature = signature
|
||||
|
||||
# Если активная кнопка находится в списке удаляемых, сбрасываем ее
|
||||
if self.gui.selected_app_button in self.installed_app_buttons:
|
||||
self.gui.selected_app_button = None
|
||||
|
||||
# Останавливаем анимации удаляемых кнопок: QPropertyAnimation живёт
|
||||
# в IconManager и без остановки переживёт кнопку, обращаясь к удалённому виджету
|
||||
for btn in self.installed_app_buttons:
|
||||
self.icon_manager.stop_button_animation(btn)
|
||||
|
||||
# Полностью очищаем layout перед обновлением
|
||||
while self.installed_scroll_layout.count():
|
||||
item = self.installed_scroll_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget:
|
||||
widget.deleteLater()
|
||||
self.installed_app_buttons.clear()
|
||||
|
||||
for i, app_info in enumerate(apps):
|
||||
desktop_path = app_info["path"]
|
||||
display_name = app_info["display_name"]
|
||||
icon_path = app_info.get("icon")
|
||||
|
||||
frame, btn = self.icon_manager.create_button_with_frame(
|
||||
display_name,
|
||||
[icon_path] if icon_path else [],
|
||||
self.gui.INSTALLED_APP_BUTTON_STYLE,
|
||||
self.gui.SELECTION_FRAME_DEFAULT,
|
||||
)
|
||||
|
||||
# Клик по кнопке показывает информацию о приложении
|
||||
btn.clicked.connect(lambda _, p=desktop_path, b=btn: self.show_installed_app_info(p, b))
|
||||
|
||||
# Привязываем кнопку к приложению и помечаем запущенные зелёной рамкой
|
||||
btn.setProperty("desktop_path", desktop_path)
|
||||
if self.app_launcher.is_app_running(desktop_path):
|
||||
btn.setStyleSheet(self.gui.RUNNING_APP_BUTTON_STYLE)
|
||||
|
||||
row = i // 2
|
||||
column = i % 2
|
||||
self.installed_scroll_layout.addWidget(frame, row, column)
|
||||
self.installed_app_buttons.append(btn)
|
||||
|
||||
# Переприменяем фильтр (может быть активен поиск) и метку пустого состояния
|
||||
self.filter_installed_buttons()
|
||||
|
||||
def show_installed_app_info(self, desktop_path, button_widget):
|
||||
"""Показывает информацию об установленном приложении в правой панели."""
|
||||
self.gui.set_active_button(button_widget)
|
||||
# Если в поиске был текст, очищаем его и перерисовываем список.
|
||||
if self.installed_search_edit.text():
|
||||
self.installed_search_edit.blockSignals(True)
|
||||
self.installed_search_edit.clear()
|
||||
self.installed_search_edit.blockSignals(False)
|
||||
self.filter_installed_buttons()
|
||||
|
||||
# Прокручиваем к выбранному элементу
|
||||
frame = button_widget.parent()
|
||||
if isinstance(frame, QFrame):
|
||||
QTimer.singleShot(0, lambda: self.installed_scroll_area.ensureWidgetVisible(frame))
|
||||
|
||||
# Используем сервис для чтения .desktop файла
|
||||
app_info = self.desktop_file_manager.read_desktop_file(os.path.splitext(os.path.basename(desktop_path))[0])
|
||||
|
||||
self.current_selected_app = {
|
||||
"desktop_path": desktop_path,
|
||||
"name": app_info.get("Name", ""),
|
||||
"exec": app_info.get("Exec", ""),
|
||||
}
|
||||
|
||||
is_app_running = self.app_launcher.is_app_running(desktop_path)
|
||||
self._set_run_button_state(is_app_running)
|
||||
|
||||
# Показываем панель информации
|
||||
self.gui.info_panel.set_visible(True)
|
||||
|
||||
# Показываем информацию в правой панели
|
||||
self.gui.info_panel.set_title_icon(QPixmap())
|
||||
self.gui.info_panel.set_title(app_info.get("Name", ""))
|
||||
self.gui.info_panel.set_title_visible(True)
|
||||
self.gui.info_panel.set_description_visible(False)
|
||||
|
||||
# Управляем видимостью кнопок через фасадные методы вкладки и окна
|
||||
self.gui.set_installer_action_visible(False)
|
||||
self.gui.set_manual_install_path_visible(False)
|
||||
self.show_app_panel()
|
||||
|
||||
# Кнопка "Дополнительные настройки приложения" неактивна когда приложение запущено
|
||||
self.app_additional_settings_button.setEnabled(not is_app_running)
|
||||
|
||||
def update_open_log_dir_button_state(self):
|
||||
"""Проверяет наличие лог-файла или бэкапов и обновляет состояние кнопки."""
|
||||
log_dir_path = EnvironmentConfig.WH_BACKUP_LOG_DIR
|
||||
has_content = False
|
||||
if os.path.isdir(log_dir_path):
|
||||
# Кнопка активна, если директория не пуста.
|
||||
if os.listdir(log_dir_path):
|
||||
has_content = True
|
||||
self.open_log_dir_button.setEnabled(has_content)
|
||||
|
||||
def open_log_directory(self):
|
||||
"""Открывает директорию с лог-файлами."""
|
||||
log_dir_path = EnvironmentConfig.WH_BACKUP_LOG_DIR
|
||||
if os.path.isdir(log_dir_path):
|
||||
try:
|
||||
subprocess.Popen(["xdg-open", log_dir_path])
|
||||
logger.debug("Открыта директория с логами: %s", log_dir_path)
|
||||
except Exception as e:
|
||||
logger.error("Не удалось открыть директорию с логами %s: %s", log_dir_path, e)
|
||||
QMessageBox.warning(self, "Ошибка", f"Не удалось открыть директорию:\n{log_dir_path}\n\nОшибка: {e}")
|
||||
else:
|
||||
logger.warning("Директория с логами не найдена: %s", log_dir_path)
|
||||
QMessageBox.information(self, "Информация", f"Директория с логами не найдена:\n{log_dir_path}")
|
||||
|
||||
def get_prefix_name_for_selected_app(self):
|
||||
"""Извлекает имя префикса для выбранного приложения."""
|
||||
if not self.current_selected_app or "desktop_path" not in self.current_selected_app:
|
||||
return None
|
||||
return self.get_prefix_name_from_desktop_file(self.current_selected_app["desktop_path"])
|
||||
|
||||
def get_prefix_name_from_desktop_file(self, desktop_file):
|
||||
"""Извлекает имя префикса из Exec-строки указанного .desktop файла."""
|
||||
if not os.path.exists(desktop_file):
|
||||
return None
|
||||
|
||||
try:
|
||||
entry = DesktopEntry(desktop_file)
|
||||
entry.load()
|
||||
exec_line = entry.get("Desktop Entry", "Exec", "")
|
||||
if not exec_line:
|
||||
return None
|
||||
|
||||
match = re.search(r"prefixes/([^/]+)", exec_line)
|
||||
if match:
|
||||
prefix_path = match.group(1)
|
||||
return os.path.basename(os.path.normpath(prefix_path))
|
||||
except Exception as e:
|
||||
logger.error("Не удалось определить имя префикса из %s: %s", desktop_file, e)
|
||||
|
||||
return None
|
||||
|
||||
def backup_prefix_for_app(self):
|
||||
"""Создает резервную копию префикса для выбранного приложения."""
|
||||
prefix_name = self.get_prefix_name_for_selected_app()
|
||||
if not prefix_name:
|
||||
QMessageBox.warning(self, "Ошибка", "Не удалось определить префикс для выбранного приложения.")
|
||||
return
|
||||
|
||||
if not DialogUtils.confirm(
|
||||
self,
|
||||
"Создание резервной копии",
|
||||
f"Будет создана резервная копия префикса '{prefix_name}'.\n\n"
|
||||
"Файл будет сохранен в домашней директории в папке "
|
||||
"winehelper_backup_log/ в формате .whpack.\n\n"
|
||||
"Продолжить?",
|
||||
):
|
||||
return
|
||||
|
||||
def on_finished(context, exit_code, exit_status):
|
||||
self.command_runner.handle_finished(context, exit_code, exit_status)
|
||||
if exit_code == 0:
|
||||
self.update_open_log_dir_button_state()
|
||||
|
||||
context = self.command_runner.create_dialog(
|
||||
f"Резервное копирование: {prefix_name}",
|
||||
self.winehelper_path,
|
||||
["backup-prefix", prefix_name],
|
||||
finished_callback=on_finished,
|
||||
)
|
||||
context.dialog.exec()
|
||||
|
||||
def restore_prefix(self):
|
||||
"""Восстанавливает префикс из резервной копии."""
|
||||
backup_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Выберите файл резервной копии для восстановления",
|
||||
os.path.expanduser("~"),
|
||||
"WineHelper Backups (*.whpack);;Все файлы (*)",
|
||||
)
|
||||
|
||||
if not backup_path:
|
||||
return
|
||||
|
||||
backup_path = os.path.abspath(backup_path)
|
||||
|
||||
if not backup_path.lower().endswith(".whpack"):
|
||||
QMessageBox.critical(self, "Ошибка", "Выбранный файл не является резервной копией WineHelper (.whpack).")
|
||||
return
|
||||
|
||||
if not os.path.isfile(backup_path):
|
||||
QMessageBox.critical(self, "Ошибка", f"Файл резервной копии не найден:\n{backup_path}")
|
||||
return
|
||||
|
||||
def on_finished(context, exit_code, exit_status):
|
||||
self.gui.handle_restore_finished(context, exit_code, exit_status)
|
||||
|
||||
context = self.command_runner.create_dialog(
|
||||
f"Восстановление из: {os.path.basename(backup_path)}",
|
||||
self.winehelper_path,
|
||||
["restore-prefix", backup_path],
|
||||
finished_callback=on_finished,
|
||||
)
|
||||
context.dialog.exec()
|
||||
|
||||
def run_installed_app_with_debug(self):
|
||||
"""Запускает выбранное установленное приложение с созданием лога"""
|
||||
app_running = False
|
||||
if self.current_selected_app:
|
||||
app_running = self.app_launcher.is_app_running(self.current_selected_app.get("desktop_path", ""))
|
||||
if app_running:
|
||||
QMessageBox.information(
|
||||
self, "Приложение запущено", "Приложение уже запущено. Остановите его, прежде чем запускать с отладкой."
|
||||
)
|
||||
return
|
||||
|
||||
dialog = CreateLogDialog(self)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
|
||||
self._run_app_launcher(debug=True, winedebug_value=dialog.winedebug_value)
|
||||
|
||||
def open_app_additional_settings(self):
|
||||
"""Открывает диалог дополнительных настроек для выбранного приложения."""
|
||||
if not self.current_selected_app or "desktop_path" not in self.current_selected_app:
|
||||
QMessageBox.warning(self, "Ошибка", "Сначала выберите приложение.")
|
||||
return
|
||||
|
||||
desktop_path = self.current_selected_app["desktop_path"]
|
||||
exec_cmd = self.current_selected_app.get("exec", "")
|
||||
|
||||
exe_path = self._get_exe_path_from_desktop(desktop_path)
|
||||
|
||||
if not exe_path:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Ошибка",
|
||||
f"Не удалось найти исполняемый файл приложения.\n\n"
|
||||
f"Путь к .desktop: {desktop_path}\n"
|
||||
f"Exec: {exec_cmd}\n\n"
|
||||
f"Проверьте, что приложение корректно установлено.",
|
||||
)
|
||||
return
|
||||
|
||||
if not os.path.exists(exe_path):
|
||||
QMessageBox.warning(self, "Ошибка", f"Исполняемый файл не найден:\n{exe_path}")
|
||||
return
|
||||
|
||||
dialog = AppAdditionalSettingsDialog(exe_path, self)
|
||||
dialog.exec()
|
||||
|
||||
def _get_exe_path_from_desktop(self, desktop_path):
|
||||
"""Извлекает путь к .exe файлу из .desktop файла."""
|
||||
app_name = os.path.splitext(os.path.basename(desktop_path))[0]
|
||||
app_info = self.desktop_file_manager.read_desktop_file(app_name)
|
||||
exec_line = app_info.get("Exec", "")
|
||||
if not exec_line:
|
||||
return None
|
||||
|
||||
desktop_dir = os.path.dirname(desktop_path)
|
||||
return DesktopFileManager.get_exe_path_from_exec(exec_line, base_dir=desktop_dir)
|
||||
|
||||
def toggle_run_stop_app(self):
|
||||
"""Запускает или останавливает выбранное приложение."""
|
||||
if not self.current_selected_app:
|
||||
QMessageBox.warning(self, "Ошибка", "Сначала выберите приложение.")
|
||||
return
|
||||
if "desktop_path" not in self.current_selected_app:
|
||||
QMessageBox.warning(self, "Ошибка", "Сначала выберите приложение.")
|
||||
return
|
||||
|
||||
desktop_path = self.current_selected_app["desktop_path"]
|
||||
|
||||
# Если приложение запущено, останавливаем его
|
||||
if self.app_launcher.is_app_running(desktop_path):
|
||||
if self._is_stopping:
|
||||
return
|
||||
|
||||
process = self.app_launcher.get_process_for_app(desktop_path)
|
||||
if process and process.state() != QProcess.ProcessState.NotRunning:
|
||||
self._is_stopping = True
|
||||
self.run_button.setText("Ожидайте…")
|
||||
self.run_button.setStyleSheet(self.gui.WAIT_BUTTON_STYLE)
|
||||
self.run_button.setEnabled(False)
|
||||
|
||||
prefix_name = self.get_prefix_name_for_selected_app()
|
||||
if not prefix_name:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Ошибка",
|
||||
"Не удалось определить префикс для остановки приложения.\n"
|
||||
"Попробуйте закрыть приложение вручную.",
|
||||
)
|
||||
self._is_stopping = False
|
||||
self.run_button.setText("Остановить")
|
||||
self.run_button.setStyleSheet(self.gui.STOP_BUTTON_STYLE)
|
||||
self.run_button.setEnabled(True)
|
||||
ProcessUtils.stop_process(process, 1000)
|
||||
return
|
||||
|
||||
prefix_path = self.prefix_manager.get_prefix_path(prefix_name)
|
||||
wine_executable = self.gui.prefix_tab.get_wine_executable_for_prefix(prefix_name)
|
||||
|
||||
wineserver_path = os.path.join(os.path.dirname(wine_executable), "wineserver")
|
||||
if not shutil.which(wineserver_path):
|
||||
wineserver_path = "wineserver"
|
||||
|
||||
kill_proc = QProcess(self)
|
||||
ProcessUtils.prepare_process(kill_proc, {"WINEPREFIX": prefix_path})
|
||||
|
||||
def on_wineserver_finished(exit_code, exit_status):
|
||||
if exit_code == 0:
|
||||
self.install_runner.append_log(f"Приложения в префиксе '{prefix_name}' остановлены.")
|
||||
else:
|
||||
# wineserver -k даёт ненулевой код, когда wine в префиксе не запущен
|
||||
# (например, приложение уже завершилось, а обёртка создаёт архив лога) —
|
||||
# это не ошибка, кнопка вернётся в «Запустить» по завершении процесса приложения
|
||||
logger.warning(
|
||||
"wineserver -k завершился с кодом %d (статус: %s): wine в префиксе '%s' уже не запущен",
|
||||
exit_code,
|
||||
exit_status,
|
||||
prefix_name,
|
||||
)
|
||||
self.install_runner.append_log(
|
||||
f"wineserver -k завершился с кодом {exit_code}: "
|
||||
f"wine в префиксе '{prefix_name}' уже не запущен."
|
||||
)
|
||||
kill_proc.deleteLater()
|
||||
|
||||
def on_wineserver_error(error):
|
||||
if error == QProcess.ProcessError.FailedToStart:
|
||||
logger.error("Не удалось запустить wineserver: %s", wineserver_path)
|
||||
self.install_runner.append_log(
|
||||
f"Ошибка: не удалось запустить wineserver для префикса '{prefix_name}'."
|
||||
)
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Ошибка",
|
||||
"Не удалось запустить wineserver для остановки приложений.\n"
|
||||
"Проверьте, что Wine установлен в префиксе.",
|
||||
)
|
||||
self._restore_stop_state()
|
||||
kill_proc.deleteLater()
|
||||
|
||||
kill_proc.finished.connect(on_wineserver_finished)
|
||||
kill_proc.errorOccurred.connect(on_wineserver_error)
|
||||
|
||||
self.install_runner.append_log(f"Остановка приложений в префиксе '{prefix_name}'...")
|
||||
logger.debug("Остановка приложений в префиксе %s через wineserver -k", prefix_path)
|
||||
kill_proc.start(wineserver_path, ["-k"])
|
||||
else:
|
||||
# Состояние не совпадает, убираем из словаря
|
||||
logger.info("Процесс для %s уже не запущен, очистка.", desktop_path)
|
||||
self._on_app_process_finished(desktop_path)
|
||||
|
||||
# Если приложение не запущено, запускаем его
|
||||
else:
|
||||
# Запускаем без отладки. Кнопка отладки отдельная.
|
||||
self._run_app_launcher(debug=False)
|
||||
|
||||
def _on_app_process_finished(self, desktop_path):
|
||||
"""Обрабатывает завершение процесса запущенного приложения."""
|
||||
process = self.app_launcher.mark_app_finished(desktop_path)
|
||||
if process:
|
||||
process.deleteLater() # Clean up the QProcess object
|
||||
logger.info("Процесс для %s завершен.", desktop_path)
|
||||
|
||||
# Если текущее выбранное приложение - то, что только что завершилось, обновляем кнопку
|
||||
if self.current_selected_app and self.current_selected_app.get("desktop_path") == desktop_path:
|
||||
self._set_run_button_state(False)
|
||||
# Сбрасываем зелёную рамку на кнопке в сетке
|
||||
self._set_app_running_indicator(desktop_path, False)
|
||||
else:
|
||||
# Процесс уже убран из реестра: например, неудачный старт
|
||||
# очищает реестр в _on_start_error до сигнала finished
|
||||
logger.debug("Получен сигнал finished для неизвестного процесса %s", desktop_path)
|
||||
|
||||
def _set_app_running_indicator(self, desktop_path, running):
|
||||
"""Помечает кнопку приложения в сетке 'Установленные' зелёной рамкой (запущено) или сбрасывает её."""
|
||||
for btn in self.installed_app_buttons:
|
||||
if btn.property("desktop_path") == desktop_path:
|
||||
btn.setStyleSheet(self.gui.RUNNING_APP_BUTTON_STYLE if running else self.gui.INSTALLED_APP_BUTTON_STYLE)
|
||||
break
|
||||
|
||||
def _restore_stop_state(self) -> None:
|
||||
"""Возвращает кнопку Запустить/Остановить в состояние по фактическому статусу приложения."""
|
||||
self._is_stopping = False
|
||||
desktop_path = self.current_selected_app.get("desktop_path") if self.current_selected_app else None
|
||||
is_running = bool(desktop_path) and self.app_launcher.is_app_running(desktop_path)
|
||||
if is_running:
|
||||
self.run_button.setEnabled(True)
|
||||
self._set_run_button_state(is_running)
|
||||
|
||||
def _set_run_button_state(self, is_running):
|
||||
"""Устанавливает текст и стиль для кнопки Запустить/Остановить."""
|
||||
if is_running:
|
||||
self.run_button.setText("Остановить")
|
||||
self.run_button.setStyleSheet(self.gui.STOP_BUTTON_STYLE)
|
||||
self.create_log_button.setEnabled(False)
|
||||
self.backup_button.setEnabled(False)
|
||||
self.uninstall_button.setEnabled(False)
|
||||
self.restore_prefix_button_panel.setEnabled(False)
|
||||
self.app_additional_settings_button.setEnabled(False)
|
||||
else:
|
||||
self.run_button.setText("Запустить")
|
||||
self.run_button.setStyleSheet(self.gui.RUN_BUTTON_STYLE)
|
||||
self.run_button.setEnabled(True)
|
||||
self.create_log_button.setEnabled(True)
|
||||
self.backup_button.setEnabled(True)
|
||||
self.uninstall_button.setEnabled(True)
|
||||
self.restore_prefix_button_panel.setEnabled(True)
|
||||
self.app_additional_settings_button.setEnabled(True)
|
||||
# Скрываем индикатор создания лога
|
||||
self._is_stopping = False
|
||||
if self._debug_launch_active:
|
||||
self.log_status_label.setVisible(False)
|
||||
self.log_status_label.setText("Идёт создание и сохранение лога…")
|
||||
self.log_progress_bar.setVisible(False)
|
||||
self._debug_launch_active = False
|
||||
# Обновляем состояние кнопки лога
|
||||
self.update_open_log_dir_button_state()
|
||||
|
||||
def _run_app_launcher(self, debug=False, winedebug_value=None):
|
||||
"""Внутренний метод для запуска приложения (с отладкой или без) с использованием QProcess."""
|
||||
if not self.current_selected_app or "exec" not in self.current_selected_app:
|
||||
QMessageBox.warning(self, "Ошибка", "Сначала выберите приложение.")
|
||||
return
|
||||
|
||||
desktop_path = self.current_selected_app["desktop_path"]
|
||||
command_str = self.current_selected_app["exec"]
|
||||
|
||||
if self.app_launcher.is_app_running(desktop_path):
|
||||
logger.info("Приложение %s уже запущено.", self.current_selected_app.get("name"))
|
||||
return
|
||||
|
||||
try:
|
||||
command_parts = shlex.split(command_str)
|
||||
clean_command = [part for part in command_parts if not part.startswith("%")]
|
||||
|
||||
# Собираем переменные окружения из команды
|
||||
env_vars = {}
|
||||
cmd_start_index = 0
|
||||
if clean_command and clean_command[0] == "env":
|
||||
cmd_start_index = 1
|
||||
while cmd_start_index < len(clean_command) and "=" in clean_command[cmd_start_index]:
|
||||
key, value = clean_command[cmd_start_index].split("=", 1)
|
||||
env_vars[key] = value.strip("\"'")
|
||||
cmd_start_index += 1
|
||||
|
||||
if cmd_start_index >= len(clean_command):
|
||||
raise ValueError("Не найдена команда для выполнения в строке Exec.")
|
||||
|
||||
if debug:
|
||||
env_vars["WINEDEBUG"] = winedebug_value or DEFAULT_WINEDEBUG
|
||||
self._debug_launch_active = True
|
||||
|
||||
# Пропускаем проверку зависимостей при запуске уже установленного приложения
|
||||
env_vars["WH_SKIP_DEPS"] = "1"
|
||||
|
||||
# Формируем финальную команду как список аргументов
|
||||
final_command = clean_command[cmd_start_index:]
|
||||
|
||||
try:
|
||||
self.app_launcher.launch_app_by_command(
|
||||
desktop_path=desktop_path,
|
||||
command=final_command,
|
||||
env_vars=env_vars,
|
||||
parent=self,
|
||||
)
|
||||
# Подключаем сигналы завершения
|
||||
process = self.app_launcher.get_process_for_app(desktop_path)
|
||||
if process:
|
||||
process.finished.connect(partial(self._on_app_process_finished, desktop_path))
|
||||
process.finished.connect(self.update_open_log_dir_button_state)
|
||||
if self._debug_launch_active:
|
||||
|
||||
def _on_log_output():
|
||||
data = process.readAllStandardOutput().data().decode("utf-8", "ignore")
|
||||
if "Создаем сжатый архив" in data:
|
||||
self.log_status_label.setText("Идёт сжатие лога…")
|
||||
elif "Архив успешно создан" in data:
|
||||
self.log_status_label.setText("Сжатие лога завершено, сохранение…")
|
||||
|
||||
process.readyReadStandardOutput.connect(_on_log_output)
|
||||
else:
|
||||
# Сливаем вывод процесса: иначе буфер пайпа (~64 КиБ)
|
||||
# переполнится и приложение заблокируется на записи в stdout
|
||||
process.readyReadStandardOutput.connect(lambda: process.readAllStandardOutput())
|
||||
|
||||
self._set_run_button_state(True)
|
||||
if self._debug_launch_active:
|
||||
self.log_status_label.setVisible(True)
|
||||
self.log_progress_bar.setVisible(True)
|
||||
self._set_app_running_indicator(desktop_path, True)
|
||||
logger.info("Запущено: %s", " ".join(shlex.quote(part) for part in final_command))
|
||||
except Exception as e:
|
||||
logger.error("Не удалось запустить приложение %s: %s", desktop_path, e)
|
||||
QMessageBox.critical(
|
||||
self, "Ошибка запуска", f"Не удалось запустить команду:\n{command_str}\n\nОшибка: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Не удалось обработать команду запуска %s: %s", command_str, e)
|
||||
QMessageBox.critical(
|
||||
self, "Ошибка", f"Не удалось обработать команду запуска:\n{command_str}\n\nОшибка: {str(e)}"
|
||||
)
|
||||
|
||||
def uninstall_app(self):
|
||||
"""Удаляет выбранное установленное приложение и его префикс через winehelper."""
|
||||
if not self.current_selected_app or "desktop_path" not in self.current_selected_app:
|
||||
QMessageBox.warning(self, "Ошибка", "Сначала выберите приложение.")
|
||||
return
|
||||
|
||||
app_name = self.current_selected_app.get("name", "это приложение")
|
||||
prefix_name = self.get_prefix_name_for_selected_app()
|
||||
|
||||
if not prefix_name:
|
||||
QMessageBox.warning(self, "Ошибка", "Не удалось определить префикс для выбранного приложения.")
|
||||
return
|
||||
|
||||
if self.app_launcher.is_app_running(self.current_selected_app["desktop_path"]):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Приложение запущено",
|
||||
f'Невозможно удалить "{app_name}": приложение сейчас запущено.\n\n'
|
||||
"Сначала остановите его кнопкой «Остановить» и повторите удаление.",
|
||||
)
|
||||
return
|
||||
|
||||
if not DialogUtils.confirm(
|
||||
self,
|
||||
"Подтверждение",
|
||||
f'Вы действительно хотите удалить "{app_name}" и все связанные файлы (включая префикс "{prefix_name}")?',
|
||||
):
|
||||
return
|
||||
|
||||
context = self.command_runner.create_dialog(
|
||||
f"Удаление приложения: {app_name}",
|
||||
self.winehelper_path,
|
||||
["remove-prefix", prefix_name, "--force"],
|
||||
finished_callback=partial(self._handle_app_uninstall_finished, prefix_name),
|
||||
)
|
||||
context.dialog.exec()
|
||||
|
||||
def _handle_app_uninstall_finished(self, prefix_name, context, exit_code, exit_status):
|
||||
"""Обрабатывает завершение удаления приложения/префикса."""
|
||||
|
||||
def on_success():
|
||||
self.gui.prefix_tab.remove_prefix_from_gui_state(prefix_name)
|
||||
self.update_installed_apps()
|
||||
self.gui.reset_info_panel_to_default(TabNames.INSTALLED)
|
||||
|
||||
def on_error():
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"Ошибка удаления",
|
||||
f"Не удалось удалить приложение/префикс '{prefix_name}'.\nПодробности смотрите в логе.",
|
||||
)
|
||||
|
||||
self.command_runner.handle_finished(
|
||||
context, exit_code, exit_status, on_success_callback=on_success if exit_code == 0 else None
|
||||
)
|
||||
if exit_code != 0:
|
||||
on_error()
|
||||
|
||||
def filter_installed_buttons(self):
|
||||
"""Фильтрует кнопки установленных программ."""
|
||||
visible = self.gui.filter_buttons_in_grid(
|
||||
self.installed_search_edit.text(), self.installed_app_buttons, self.installed_scroll_layout
|
||||
)
|
||||
GridUtils.update_empty_state(
|
||||
self.empty_label, len(visible), self.installed_search_edit.text(), "Нет установленных приложений"
|
||||
)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Отображаемые имена вкладок главного окна WineHelper.
|
||||
|
||||
Единый источник строк для логики переключения вкладок: сравнения в
|
||||
обработчиках и вызовах add_tab используют только эти константы,
|
||||
чтобы переименование вкладки не ломало поведение молча.
|
||||
"""
|
||||
|
||||
|
||||
class TabNames:
|
||||
"""Имена вкладок главного окна (как они отображаются в TabBar)."""
|
||||
|
||||
AUTO_INSTALL = "Автоматическая установка"
|
||||
MANUAL_INSTALL = "Ручная установка"
|
||||
EXTERNAL_INSTALL = "Сторонняя установка"
|
||||
INSTALLED = "Установленные"
|
||||
PREFIX_MANAGER = "Менеджер префиксов"
|
||||
SETTINGS = "Настройки"
|
||||
HELP = "Справка"
|
||||
Reference in new issue
Block a user