Compare commits

..
66 Commits
Author SHA1 Message Date
Mikhail Tergoev 266302d643 updated adem pfx with jet40 2026-10-28 08:22:17 +03:00
Mikhail Tergoev 38d3a43c03 fix: dont mkdir prefix if break backup 2026-10-28 08:09:57 +03:00
Mikhail Tergoev c2c5a05013 docs: update AGENTS.md 2026-10-20 16:59:47 +03:00
Mikhail Tergoev 410b24c9b5 Merge branch 'minergenon-link' 2026-10-20 16:49:30 +03:00
Sergey Palcheh a525b9f749 docs: replace CLAUDE.md with symlink to AGENTS.md 2026-10-19 12:36:04 +06:00
Mikhail Tergoev 3c856cd890 added wine-11.19-alt-tkg-wow64 2026-10-16 17:13:19 +03:00
Mikhail Tergoev c2bf75d0da Merge branch 'minergenon-fix/gui-critical-batch1' 2026-10-16 15:20:42 +03:00
Sergey Palcheh 50a2498abb fix(gui): scroll CPCSP_PROXY dialog log to the end on finish 2026-10-16 10:57:57 +06:00
Sergey Palcheh 53ec0f88bd refactor(gui): remove work path setting from settings tab 2026-10-16 10:33:50 +06:00
Sergey Palcheh b8cebe18b7 gui: use system fonts, fix Qt6 deprecations and revive winetricks error branch 2026-10-15 20:44:05 +06:00
Sergey Palcheh fe4473ef76 perf(gui): single-pass script parser, grid caches, paused offscreen timers; license parity with CLI 2026-10-15 18:59:40 +06:00
Sergey Palcheh 7c53112cc9 refactor(gui): info panel controller, tab name constants, dead code cleanup 2026-10-15 17:30:20 +06:00
Sergey Palcheh 453b5a5a99 fix(gui): icon animation leaks, GUI thread blocks, dialog crashes, Qt6 exit hang 2026-10-15 16:07:26 +06:00
Mikhail Tergoev 4990ac941a added support for grdwine system libraries 2026-10-13 13:57:31 +03:00
Mikhail Tergoev 656e20eaa1 added manualinstall: adem 2026-10-09 15:35:33 +03:00
Mikhail Tergoev 72ccbaa747 Merge branch 'minergenon-cript' 2026-10-09 14:55:39 +03:00
Mikhail Tergoev e4bafa7a1f fixed switching between x11 and Wayland 2026-10-09 14:55:18 +03:00
Mikhail Tergoev 271fb70d74 scada-krug-2000: updated wine with fix for demo-server 2026-10-08 14:52:34 +03:00
Mikhail Tergoev 0884034a31 autoinstall: added Zet9 Lite 2026-10-08 10:24:35 +03:00
Sergey Palcheh fe37c07729 fix: use qt_compat instead of direct PyQt5 import in cryptopro_warning 2026-10-01 09:19:36 +06:00
Mikhail Tergoev 5f506223ac Merge branch 'minergenon-pyqt6-migration' 2026-09-30 11:39:21 +03:00
Mikhail Tergoev 0590170cd2 Merge branch 'minergenon-crpr' 2026-09-30 11:38:34 +03:00
Sergey Palcheh ae4eed8be9 fixing a typo in AGENTS.md 2026-09-30 10:47:38 +06:00
Sergey Palcheh 0231f7fe3f fix(gui): always show Russian button texts and startup log line 2026-09-30 10:39:53 +06:00
Sergey Palcheh e4e7828420 feat(gui): support PyQt6 alongside PyQt5 via qt_compat layer 2026-09-29 12:19:53 +06:00
Mikhail Tergoev 41ec383343 docs: add CLAUDE.md with CLI usage and podman repro recipe 2026-09-11 20:03:23 +05:00
Mikhail Tergoev 3591f657f9 scada-krug-2000: install from MSI, move to autoinstall
Root setup.exe in the vendor archive is an AutoRunHTML menu that ignores
/quiet and waits for clicks, so the install hung forever (bug #59578).
Download 01_scada_v4_4_release.zip, unpack it and run
DeveloperStudio/SCADA KRUG 2000.msi with /qn directly. Kill leftover
KrugTaskManager/SMTListener/KrugTaskStarter via wineserver -k instead of
the racy background kill_autostart.

Closes: https://bugzilla.altlinux.org/59578
2026-09-11 20:03:23 +05:00
Sergey Palcheh db88a6ebd0 cryptopro_warning module has been added 2026-09-03 09:06:10 +06:00
Mikhail Tergoev 691f13b046 Merge branch 'minergenon-test' 2026-09-02 20:57:02 +03:00
Sergey Palcheh b865533a4b refactor(gui): drop unused package-level re-exports from __init__ modules 2026-09-02 17:01:05 +06:00
Sergey Palcheh df22c2b1db refactor(gui): use DEFAULT_WINEDEBUG constant instead of literal value 2026-09-02 17:01:04 +06:00
Sergey Palcheh 3bf33340b9 refactor(gui): extract shared QProcess setup and widget-deleted guard into utils 2026-09-02 17:00:35 +06:00
Sergey Palcheh 1c221ed1cc refactor(gui): remove dead code 2026-09-02 16:19:57 +06:00
Sergey Palcheh 5fa4514665 feat(gui): distinguish user-aborted commands from errors in log 2026-09-02 16:04:14 +06:00
Sergey Palcheh a5e339b112 refactor(gui): replace closeEvent monkey-patch with InstallDialog subclass 2026-09-02 16:04:01 +06:00
Sergey Palcheh b33f476fc9 refactor(gui): two-phase UI construction in version selection dialogs 2026-09-02 13:43:32 +06:00
Sergey Palcheh 22d76d4bea refactor(gui): unify prefix command execution in prefix tab 2026-09-02 13:14:22 +06:00
Sergey Palcheh 6a5aeed900 refactor(gui): unify winetricks.log parsing in prefix info panel 2026-09-02 12:39:27 +06:00
Sergey Palcheh b462a4729c feat(gui): show empty-state hints for app grids and prefix list 2026-09-01 13:43:02 +06:00
Sergey Palcheh fb79aeaddb refactor(gui): expose cross-object APIs and move wine-util processes to prefix tab 2026-09-01 13:12:00 +06:00
Sergey Palcheh 47fbff3a0f fix(gui): delete finished QProcess objects to stop memory leaks 2026-08-31 18:59:54 +06:00
Sergey Palcheh e6051aa04b refactor(gui): unify winedebug dialogs, separate remembered channels per mode 2026-08-31 17:41:33 +06:00
Sergey Palcheh 552c118595 fix(gui): ignore repeat close clicks during shutdown, add killall safety net 2026-08-31 17:01:34 +06:00
Sergey Palcheh 906ad8cae6 fix: per-second timestamps for prefix backups and wine logs 2026-08-31 16:27:45 +06:00
Sergey Palcheh cb6255454a fix(gui): async wine --version cache and instant command dialog close 2026-08-31 15:17:37 +06:00
Sergey Palcheh daf0758d75 fix(gui): hold waiting stop state until app actually stops 2026-08-31 14:18:05 +06:00
Sergey Palcheh c8deeb1b8a fix(gui): dialog progress, console mirror, command exit codes 2026-08-29 19:34:19 +06:00
Sergey Palcheh 9a8c185846 refactor(gui): decouple InstalledTab info-panel state from main window 2026-08-28 17:33:41 +06:00
Mikhail Tergoev 66f11f384d Merge branch 'minergenon-gui-improvements' 2026-08-25 12:53:16 +03:00
Sergey Palcheh e1701fbac9 refactor(gui): remove dead code across services, utils and dialogs 2026-08-25 10:37:07 +06:00
Sergey Palcheh e6717908ac refactor(gui): extract command dialogs into services/command_runner.py 2026-08-24 19:08:38 +06:00
Sergey Palcheh 68c665fb8e refactor(gui): extract install tabs into widgets/install_tab.py 2026-08-24 18:54:09 +06:00
Sergey Palcheh d83ef56e0e refactor(gui): extract installed apps tab into widgets/installed_tab.py 2026-08-24 16:50:16 +06:00
Sergey Palcheh 261b70fbe0 refactor(gui): extract prefix manager tab into widgets/prefix_tab.py 2026-08-24 16:36:03 +06:00
Sergey Palcheh b76c6f25f5 refactor(gui): extract install process controller into services/install_runner.py 2026-08-24 15:38:00 +06:00
Sergey Palcheh 5e22af5b4b refactor(gui): extract help tab into widgets/help_tab.py 2026-08-24 15:24:24 +06:00
Sergey Palcheh f84e8a9de3 fix(gui): handle missing system wine in version selection dialog 2026-08-24 14:49:57 +06:00
Sergey Palcheh 422a22562f refactor(gui): drop unused desktop file getters 2026-08-24 13:59:26 +06:00
Sergey Palcheh 1caa6e2fc1 refactor(gui): unify theme icon lookup via IconManager.theme_icon() 2026-08-24 13:35:16 +06:00
Mikhail Tergoev 547a705e37 Merge branch 'minergenon-devel' 2026-08-17 12:28:00 +03:00
Sergey Palcheh 8d69318f11 fix(gui): drain process output to prevent pipe buffer deadlock 2026-08-02 13:29:28 +06:00
Sergey Palcheh e68636758c feat(gui): colorize log levels in terminal output 2026-08-02 12:58:49 +06:00
Sergey Palcheh 1f94dfd0e2 feat(gui): add WINEDEBUG logging toggle to prefix manager tab 2026-07-31 18:41:36 +06:00
Sergey Palcheh 50bd638ad7 feat(gui): show settings tab only in development mode 2026-07-31 17:18:35 +06:00
Mikhail Tergoev b07c35e879 scadoffice: update wine for fix hasplmd 2026-07-29 16:04:24 +03:00
Mikhail Tergoev 0baa84dbed drop wait_wineserver from debug_/wine_run 2026-07-29 16:03:40 +03:00
64 changed files with 5423 additions and 4648 deletions

No files matched your search

+94 -9
View File
@@ -46,6 +46,7 @@ winehelper/
│ │ ├── create_prefix.py # Создание префикса
│ │ ├── file_associations.py
│ │ ├── noexec_warning.py # Предупреждение о noexec
│ │ ├── prefix_logging.py # Включение/выключение логирования на вкладке префиксов
│ │ ├── settings.py # Настройки префикса
│ │ ├── windows_version.py # Выбор версии Windows
│ │ ├── wine_version.py # Выбор версии Wine
@@ -56,7 +57,9 @@ winehelper/
│ │ └── winetricks_parser.py
│ ├── services/ # Бизнес-логика
│ │ ├── app_launcher.py # Запуск приложений
│ │ ├── command_runner.py # Диалоги выполнения команд winehelper (бэкап, удаление и т.п.)
│ │ ├── desktop_file_manager.py
│ │ ├── install_runner.py # Диалог и процесс установки приложений
│ │ ├── prefix_manager.py # Управление префиксами
│ │ ├── script_manager.py
│ │ ├── settings_manager.py
@@ -67,13 +70,20 @@ winehelper/
│ │ ├── 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/ # Кастомные виджеты
│ └── 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 приложений)
@@ -123,6 +133,13 @@ winehelper/
- **Диалоги** — модальные окна для настроек и выбора (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`) — утечки были реальным багом.
### Скрипты установки
Каждый скрипт установки — это bash-скрипт с переменными:
@@ -146,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 перед коммитом.
## Режимы работы
### Системный режим
@@ -158,16 +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 "/path/to/script" # Установить из стороннего скрипта
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 # Создать префикс
@@ -182,9 +229,11 @@ winehelper winereg # Редактор реестра
winehelper wineconsole # Терминал Wine (cmd.exe)
winehelper winetricks [component] # Winetricks
winehelper <file.exe> # Запустить exe в префиксе
winehelper run <exe_name> # Запустить установленную программу (префикс берётся из .whdb)
winehelper debug [флаги] [программа] # Запуск программы в отладчике winedbg
winehelper killall # Остановить все процессы Wine
winehelper run-in-prefix <префикс> <файл> # Запустить файл в указанном префиксе
winehelper killall # Остановить все процессы Wine, запущенные из USER_WORK_PATH
winehelper run-in-prefix <префикс> <файл> # Запустить произвольный exe/msi в указанном префиксе
winehelper --help # Полный список подкоманд (функция wh_info)
# Управление компонентами
winehelper install-dxvk [version] # Установить DXVK
@@ -197,6 +246,22 @@ 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`).
## Разработка
### Запуск в режиме разработки
@@ -210,6 +275,10 @@ winehelper gui # Запустить GUI
./winehelper -i alta-expert
```
### Стиль коммитов
Коммиты оформляются по Conventional Commits с указанием области: `feat(gui): ...`, `refactor(gui): ...`, `fix(gui): ...` и т. п. Документация, комментарии, сообщения лога и строки интерфейса — на русском; так же оформляется и новый код.
### Pre-commit
Проект использует [pre-commit](https://pre-commit.com/) для автоматической проверки кода перед коммитом.
@@ -293,9 +362,16 @@ pre-commit autoupdate
- Проверять наличие переменных перед использованием
- Использовать функции обработки ошибок (`fatal`, `print_error`)
- Экранировать все пути с помощью кавычек
- Предпочитать готовые помощники (`try_remove_file`, `try_copy_dir`, `create_new_dir`, `try_download`) сырым `rm`/`cp`/`wget`
**Python:**
- Импортировать из `winehelperqt` относительно
- Импорты — только абсолютные (`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)
@@ -307,8 +383,17 @@ pre-commit autoupdate
```
Логгер настраивается один раз в `main()` (`winehelper_gui.py`) через `setup_logger()`.
Уровень задаётся переменной окружения `WH_LOG_LEVEL` (debug/info/warning/error, по умолчанию info)
либо во вкладке «Настройки» GUI.
либо во вкладке «Настройки» 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 ""`.
## Безопасность
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+34
View File
@@ -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
+22
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+28
View File
@@ -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
-25
View File
@@ -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
+1 -1
View File
@@ -2,7 +2,7 @@
line-length = 120
[tool.ruff.lint]
select = ["E", "W", "F", "I"]
select = ["E", "W", "F", "I", "ISC"]
[tool.ruff.format]
quote-style = "double"
+10 -2
View File
@@ -1,9 +1,11 @@
##### 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
@@ -14,6 +16,7 @@ d1bfda024138f6e83ee531df513e6ebebb7aee1e40ce3f5cd3a4d0631107e482 wine-11.12-alt
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
@@ -191,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)
@@ -222,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
+10 -8
View File
@@ -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++")
+58 -11
View File
@@ -157,7 +157,7 @@ WH_TMP_DIR="$USER_WORK_PATH/tmp"
export SCRIPT_NAME USER_WORK_PATH RUN_SCRIPT DATA_PATH CHANGELOG_FILE WH_ICON_PATH \
LICENSE_FILE AGREEMENT THIRD_PARTY_FILE WH_ICON_TRAY GENERAL WH_WINETRICKS \
WH_TMP_DIR WH_DIST_DIR WH_NOEXEC_HOME WH_CONFIG
WH_TMP_DIR WH_DIST_DIR WH_NOEXEC_HOME WH_CONFIG WH_DEVEL
[[ ! -d "$WH_TMP_DIR" ]] && mkdir -p "$WH_TMP_DIR"
@@ -227,6 +227,8 @@ check_variables WH_USE_CPCSP_PROXY "0"
check_variables CPCSP_PROXY_X86_64_VER "0.6.1-alt1"
check_variables CPCSP_PROXY_WOW64_VER "0.7.7-alt1-wow64"
check_variables WH_USE_GRDWINE "0"
check_variables WH_USE_EXTRA_FONTS "0"
check_variables EXTRA_FONTS_VER "01"
check_variables WH_FONT_MSS_REPLACE "0"
@@ -489,8 +491,12 @@ print_license_agreement() {
fi
if [[ -f "$AGREEMENT" ]]; then
local agreement_text
if ! agreement_text="$(cat "$AGREEMENT")"; then
fatal "Не удалось прочитать файл лицензионного соглашения: $AGREEMENT"
fi
echo
print_warning "$(cat "$AGREEMENT")"
print_warning "$agreement_text"
else
fatal "Файл лицензионного соглашения не найден: $AGREEMENT"
fi
@@ -603,6 +609,31 @@ tmp_winedlloverride_update() {
var_winedlloverride_update "$1"
}
try_copy_grdwine_file() {
local rel="$1"
local arch_dir="${rel%/*}"
local dest_dir=""
if [[ -d "$WINEDIR/lib64/wine/$arch_dir" ]]; then
dest_dir="$WINEDIR/lib64/wine/$arch_dir"
elif [[ -d "$WINEDIR/lib/wine/$arch_dir" ]]; then
dest_dir="$WINEDIR/lib/wine/$arch_dir"
else
return 0
fi
local src=""
if [[ -f "/usr/lib64/wine/$rel" ]]; then
src="/usr/lib64/wine/$rel"
elif [[ -f "/usr/lib/wine/$rel" ]]; then
src="/usr/lib/wine/$rel"
else
return 1
fi
cp -f "$src" "$dest_dir/"
}
var_dxvk_config_update() {
if [[ -n "${DXVK_CONFIG}" ]]; then
export DXVK_CONFIG="${1};${DXVK_CONFIG}"
@@ -914,7 +945,10 @@ check_installed_programs() {
;;
esac
done
[[ -n $2 ]] && fatal "Не найден файл запуска для $2"
if [[ -n $2 ]]; then
fatal "Не найден файл запуска для $2"
fi
return 0
}
copy_wined3d() {
@@ -1166,6 +1200,18 @@ init_wine_ver() {
fi
fi
if [[ $WH_USE_GRDWINE == "1" ]]; then
local grdwine_rel grdwine_pkg="wine-grdwine"
if [[ $WH_WINE_WOW64 != "1" ]]; then
grdwine_pkg="i586-wine-grdwine"
fi
for grdwine_rel in i386-windows/grdwine.dll x86_64-windows/grdwine.dll \
i386-unix/grdwine.so x86_64-unix/grdwine.so; do
try_copy_grdwine_file "$grdwine_rel" || fatal "Для работы с ключом защиты Guardant установите в систему пакет $grdwine_pkg:
apt-get install $grdwine_pkg"
done
fi
export WINE="$WINEDIR/bin/wine"
export WINELOADER="$WINEDIR/bin/wine"
export WINESERVER="$WINEDIR/bin/wineserver"
@@ -1398,12 +1444,12 @@ init_wineprefix() {
export XUSER_PATH="$DRIVE_C/users/$WINEUSERNAME"
if [[ ! -d "$WINEPREFIX" ]]; then
create_new_dir "$WINEPREFIX"
if [[ "$CLEAR_PREFIX" == "1" ]]; then
print_warning "Используется переменная \"CLEAR_PREFIX=1\", которая принудительно создает чистый префикс с установкой компонентов с помощью winetricks."
elif [[ "$BASE_PFX" != "none" ]]; then
get_base_pfx "$BASE_PFX"
fi
create_new_dir "$WINEPREFIX"
fi
if [[ ! -d "$WINEPREFIX/drive_c/windows" ]]; then
@@ -1481,7 +1527,7 @@ init_wineprefix() {
get_and_set_reg_file --add 'Software\Wine\Drivers' 'Graphics' 'REG_SZ' "x11,wayland" "user"
else
unset WINE_WAYLAND_HACKS
get_and_set_reg_file --delete 'Software\Wine\Drivers' 'Graphics'
get_and_set_reg_file --add 'Software\Wine\Drivers' 'Graphics' 'REG_SZ' "x11" "user"
fi
fi
@@ -1671,7 +1717,8 @@ init_wineprefix() {
for var in WH_WINE_USE BASE_PFX WINEARCH WH_WINDOWS_VER WINEESYNC WINEFSYNC \
STAGING_SHARED_MEMORY WINE_LARGE_ADDRESS_AWARE WH_USE_SHADER_CACHE WH_USE_WINE_DXGI \
WINE_CPU_TOPOLOGY DXVK_VER VKD3D_VER WH_XDG_OPEN WH_USE_EXTRA_FONTS \
WINEUSERNAME WH_USE_CPCSP_PROXY CPCSP_PROXY_X86_64_VER CPCSP_PROXY_WOW64_VER; do
WINEUSERNAME WH_USE_CPCSP_PROXY CPCSP_PROXY_X86_64_VER CPCSP_PROXY_WOW64_VER \
WH_USE_GRDWINE; do
update_last_conf_var "$var" "${!var}"
done
@@ -1845,7 +1892,7 @@ wine_run() {
if [[ $WINEDEBUG != "-all" ]]; then
local log_dir log_file timestamp
timestamp=$(date +"%d.%m.%Y-%H.%M")
timestamp=$(date +"%d.%m.%Y-%H.%M.%S")
log_dir="$HOME/winehelper_backup_log"
log_file="$log_dir/${PREFIX_NAME}_${win_file_name%.*}_${timestamp}.log"
create_new_dir "$log_dir"
@@ -1892,7 +1939,6 @@ wine_run() {
$MANGOHUD_RUN "$WINELOADER" $wh_add_args "$win_file_exec" "$@"
fi
wait_wineserver
cd "$DRIVE_C"
}
@@ -1929,7 +1975,6 @@ wine_debug_run() {
"$WINELOADER" winedbg $winedbg_args "$win_file_exec" "$@" 2>&1 | tee -a "$log_file"
wait_wineserver
cd "$DRIVE_C"
}
@@ -2530,9 +2575,9 @@ backup_prefix() {
local backup_base_dir backup_archive_name backup_dest_path temp_backup_dir temp_prefix_dir temp_users_dir
backup_base_dir="$HOME/winehelper_backup_log"
create_new_dir "$backup_base_dir"
backup_archive_name="backup_${PREFIX_NAME}_$(date +%d.%m.%Y-%H.%M).whpack"
backup_archive_name="backup_${PREFIX_NAME}_$(date +%d.%m.%Y-%H.%M.%S).whpack"
backup_dest_path="$backup_base_dir/$backup_archive_name"
temp_backup_dir="$WH_TMP_DIR/backup_${PREFIX_NAME}_$(date +%d.%m.%Y-%H.%M)"
temp_backup_dir="$WH_TMP_DIR/backup_${PREFIX_NAME}_$(date +%d.%m.%Y-%H.%M.%S)"
temp_prefix_dir="$temp_backup_dir/prefixes/$PREFIX_NAME"
temp_users_dir="$temp_prefix_dir/drive_c/users"
@@ -3093,8 +3138,10 @@ case "$arg1" in
fi
;;
esac
command_rc=$?
if [[ -n "$WH_BG_PID" ]]; then
print_info "Завершение фонового процесса: $WH_BG_PID"
kill "$WH_BG_PID" 2> /dev/null
fi
exit "$command_rc"
+309 -2861
View File
File diff suppressed because it is too large. Load diff
+3 -50
View File
@@ -7,54 +7,7 @@ GUI пакет для winehelper.
- services: Бизнес-логика (кэширование, установка и т.д.)
- parsers: Парсеры (sha256sum.list, winetricks output)
- utils: Утилиты (обработчики логов и т.д.)
Реэкспортов нет: импорты — только абсолютные из конкретных модулей
(например, from winehelperqt.services.prefix_manager import PrefixManager).
"""
# Parsers
from winehelperqt.dialogs.noexec_warning import NoExecWarningDialog
# Dialogs
from winehelperqt.dialogs.winetricks_manager import WinetricksManagerDialog
from winehelperqt.parsers.script_parser import ScriptParser
from winehelperqt.parsers.sha256_parser import Sha256Parser
from winehelperqt.parsers.winetricks_parser import WinetricksOutputParser
from winehelperqt.services.app_launcher import AppLauncher
from winehelperqt.services.desktop_file_manager import DesktopFileManager
from winehelperqt.services.prefix_manager import PrefixManager
from winehelperqt.services.script_manager import ScriptInfo, ScriptManager
from winehelperqt.services.settings_manager import SettingsManager
# Services
from winehelperqt.services.winetricks_service import WinetricksCacheService, WinetricksLogReader
from winehelperqt.utils.cache_service import CacheService, FileCacheService
# Utils
from winehelperqt.utils.log_handler import LogProgressHandler, MultiLogHandler
# Widgets
from winehelperqt.widgets.icon_manager import IconManager
__all__ = [
# Parsers
"Sha256Parser",
"WinetricksOutputParser",
"ScriptParser",
# Utils
"LogProgressHandler",
"MultiLogHandler",
"CacheService",
"FileCacheService",
# Services
"WinetricksCacheService",
"WinetricksLogReader",
"PrefixManager",
"DesktopFileManager",
"ScriptManager",
"ScriptInfo",
"AppLauncher",
"SettingsManager",
# Widgets
"IconManager",
# Dialogs
"WinetricksManagerDialog",
"NoExecWarningDialog",
]
-35
View File
@@ -1,38 +1,3 @@
"""
Диалоги для winehelper GUI.
"""
from winehelperqt.dialogs.base_version_dialog import (
BaseVersionSelectionDialog,
ComponentVersionSelectionDialogBase,
WineVersionSelectionDialogBase,
)
from winehelperqt.dialogs.command_dialog import CommandDialog
from winehelperqt.dialogs.component_version import ComponentVersionSelectionDialog
from winehelperqt.dialogs.create_log import CreateLogDialog
from winehelperqt.dialogs.create_prefix import CreatePrefixDialog
from winehelperqt.dialogs.file_associations import FileAssociationsDialog
from winehelperqt.dialogs.noexec_warning import NoExecWarningDialog
from winehelperqt.dialogs.settings import AdditionalSettingsDialog, AppAdditionalSettingsDialog
from winehelperqt.dialogs.windows_version import WindowsVersionSelectionDialog
from winehelperqt.dialogs.wine_version import WineVersionSelectionDialog
from winehelperqt.dialogs.winetricks_manager import WinetricksManagerDialog
__all__ = [
# Base classes
"BaseVersionSelectionDialog",
"WineVersionSelectionDialogBase",
"ComponentVersionSelectionDialogBase",
# Dialogs
"CommandDialog",
"WinetricksManagerDialog",
"WineVersionSelectionDialog",
"ComponentVersionSelectionDialog",
"CreatePrefixDialog",
"FileAssociationsDialog",
"AdditionalSettingsDialog",
"AppAdditionalSettingsDialog",
"WindowsVersionSelectionDialog",
"NoExecWarningDialog",
"CreateLogDialog",
]
+93 -84
View File
@@ -4,27 +4,26 @@
"""
import os
import re
import shutil
import subprocess
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
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.logger import get_logger
from winehelperqt.parsers.sha256_parser import Sha256Parser
from winehelperqt.utils.environment import EnvironmentConfig
from winehelperqt.utils.grid_utils import GridUtils
@@ -39,18 +38,37 @@ class BaseVersionSelectionDialog(QDialog):
- Поле поиска с фильтрацией
- Сетка кнопок с версиями
- Базовая логика выбора версии
Построение UI двухфазное: конечный наследник обязан вызвать
self._setup_ui() в конце своего __init__, когда установлены все поля,
необходимые виртуальным методам (_create_version_container, populate_ui).
"""
NUM_COLUMNS = 3
# Кэш версии системного Wine на время сессии (не меняется между открытиями диалога)
# Кэш версии системного Wine на время сессии.
# None — версия ещё не определена; "" — определить не удалось
# (кэшируем и неудачу, чтобы не запускать wine --version при каждом открытии диалога);
# непустая строка — определённая версия.
_system_wine_version_cache = None
version_selected = pyqtSignal(str)
@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)
@@ -63,8 +81,6 @@ 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
@@ -100,52 +116,8 @@ class BaseVersionSelectionDialog(QDialog):
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):
"""
@@ -159,6 +131,7 @@ 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__(
@@ -178,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)
@@ -204,38 +177,74 @@ class WineVersionSelectionDialogBase(BaseVersionSelectionDialog):
return Sha256Parser.get_installed_versions(dist_path)
def _get_system_wine_version(self):
"""Получает версию системного Wine (с кэшированием на время сессии)."""
if BaseVersionSelectionDialog._system_wine_version_cache is not None:
return BaseVersionSelectionDialog._system_wine_version_cache
"""
Получает версию системного Wine (с кэшированием на время сессии).
version = "Системная версия"
if shutil.which("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 _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
proc = QProcess(self)
proc.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
def on_finished(exit_code, _exit_status):
version = ""
try:
result = subprocess.run(
["wine", "--version"], capture_output=True, text=True, check=True, encoding="utf-8"
)
version = result.stdout.strip()
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)
except (FileNotFoundError, subprocess.CalledProcessError) as e:
logger.warning("Не удалось получить версию системного wine: %s", e)
self._update_system_wine_button_text()
else:
logger.warning("Не удалось определить версию системного wine")
BaseVersionSelectionDialog._system_wine_version_cache = version
return version
def on_error(_error):
# Не перезаписываем версию, если её уже определил параллельный запрос
if BaseVersionSelectionDialog.get_system_wine_version() is None:
BaseVersionSelectionDialog.set_system_wine_version("")
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")
proc.finished.connect(on_finished)
proc.errorOccurred.connect(on_error)
proc.start("wine", ["--version"])
if is_win64_prefix and re_32bit_version.search(version_name):
return False
if is_win32_prefix:
if version_name == "system" or "wow64" in version_name:
return False
return True
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."""
@@ -261,14 +270,14 @@ class ComponentVersionSelectionDialogBase(BaseVersionSelectionDialog):
"""Создает контейнер для версий компонента."""
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)
+40 -32
View File
@@ -1,47 +1,55 @@
from PyQt5.QtCore import QProcess
from PyQt5.QtWidgets import QDialog
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):
running = False
if self._process is not None:
try:
running = self._process.state() == QProcess.Running
except RuntimeError:
running = False
if running:
proc = self._process
if not DialogUtils.confirm(
self,
"Прервать выполнение?",
"Процесс ещё выполняется.\nВы действительно хотите прервать его?",
yes_text="Да, прервать",
no_text="Нет",
):
event.ignore()
return
if proc is None:
event.accept()
return
try:
proc.terminate()
if not proc.waitForFinished(10000):
proc.kill()
proc.waitForFinished(5000)
except RuntimeError:
pass
event.accept()
else:
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()
@@ -29,6 +29,9 @@ class ComponentVersionSelectionDialog(ComponentVersionSelectionDialogBase):
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)
+13 -81
View File
@@ -2,90 +2,22 @@
Диалог создания лога запуска программы с выбором каналов WINEDEBUG.
"""
from PyQt5.QtCore import QSettings
from PyQt5.QtWidgets import (
QCheckBox,
QDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
)
DEFAULT_WINEDEBUG = "+loaddll,+seh"
SETTINGS_KEY = "winedebug_channels"
from winehelperqt.dialogs.winedebug_base import CREATE_LOG_CHANNELS_KEY, WinedebugChannelsDialog
class CreateLogDialog(QDialog):
class CreateLogDialog(WinedebugChannelsDialog):
"""Диалог для запуска приложения с созданием лога."""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Создание лога")
self.setMinimumSize(520, 240)
self.setModal(True)
self._settings = QSettings("winehelper", "winedebug_run")
layout = QVBoxLayout(self)
desc_label = QLabel(
"Приложение будет запущено в режиме отладки.\n\n"
"После закрытия приложения лог будет сохранен в папке 'winehelper_backup_log' "
"в вашем домашнем каталоге под именем (пример: prefix_program.log).\n\n"
"Укажите каналы отладки WINEDEBUG:"
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,
)
desc_label.setWordWrap(True)
layout.addWidget(desc_label)
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()
ok_button = QPushButton("Запустить")
ok_button.clicked.connect(self.accept)
cancel_button = QPushButton("Отмена")
cancel_button.clicked.connect(self.reject)
button_layout.addStretch()
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
def _restore_state(self):
saved = self._settings.value(SETTINGS_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(SETTINGS_KEY, value)
else:
self._settings.remove(SETTINGS_KEY)
super().accept()
@property
def winedebug_value(self) -> str:
value = self.winedebug_edit.text().strip()
return value or DEFAULT_WINEDEBUG
+24 -15
View File
@@ -5,8 +5,10 @@
import os
import re
from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import (
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,
@@ -19,11 +21,8 @@ from PyQt5.QtWidgets import (
QVBoxLayout,
QWidget,
)
from winehelperqt.dialogs.windows_version import WindowsVersionSelectionDialog
from winehelperqt.dialogs.wine_version import WineVersionSelectionDialog
from winehelperqt.parsers.sha256_parser import Sha256Parser
from winehelperqt.utils.environment import EnvironmentConfig
from winehelperqt.utils.font_utils import app_font
class CreatePrefixDialog(QDialog):
@@ -34,7 +33,7 @@ class CreatePrefixDialog(QDialog):
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)
@@ -107,7 +106,7 @@ class CreatePrefixDialog(QDialog):
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)
@@ -123,6 +122,9 @@ 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()
@@ -146,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
@@ -166,10 +168,16 @@ class CreatePrefixDialog(QDialog):
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):
"""Проверяет имя префикса в реальном времени."""
@@ -214,8 +222,9 @@ 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()
+96
View File
@@ -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;">&nbsp;</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())
+7 -8
View File
@@ -2,8 +2,7 @@
Диалог для управления ассоциациями файлов (WH_XDG_OPEN).
"""
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QLabel, QLineEdit, QMessageBox, QVBoxLayout
from winehelperqt.qt_compat import QDialog, QDialogButtonBox, QLabel, QLineEdit, QMessageBox, Qt, QVBoxLayout
class FileAssociationsDialog(QDialog):
@@ -26,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,10 +36,10 @@ class FileAssociationsDialog(QDialog):
layout.addWidget(self.associations_edit)
forbidden_label = QLabel("<small><b>Запрещено использовать:</b> cpl, dll, exe, lnk, msi</small>")
forbidden_label.setTextFormat(Qt.RichText)
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)
@@ -56,14 +55,14 @@ class FileAssociationsDialog(QDialog):
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)))
+104 -78
View File
@@ -2,25 +2,25 @@
Диалог/виджет глобальных настроек WineHelper GUI.
"""
from pathlib import Path
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
from winehelperqt.logger import get_logger, setup_logger
from winehelperqt.qt_compat import (
CURRENT_BINDING,
QApplication,
QComboBox,
QDialog,
QFileDialog,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QProcess,
QPushButton,
Qt,
QVBoxLayout,
QWidget,
available_bindings,
)
from winehelperqt.logger import get_logger, setup_logger
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__)
@@ -40,6 +40,13 @@ class GlobalSettingsWidget(QWidget):
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)
@@ -60,34 +67,31 @@ class GlobalSettingsWidget(QWidget):
form_layout.addRow(self.log_level_label, self.log_level_combo)
self.work_path_label = QLabel("Рабочий каталог:")
self.work_path_label.setToolTip(
"Каталог, в котором хранятся префиксы, версии Wine и загруженные файлы.\n"
"Аналог команды: winehelper data-path /путь/к/каталогу\n"
"Данные из текущего каталога не переносятся автоматически.\n"
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.work_path_edit = QLineEdit()
self.work_path_edit.setMinimumWidth(200)
self.work_path_edit.setPlaceholderText("/путь/к/каталогу")
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")
self.work_path_browse_button = QPushButton("Обзор...")
self.work_path_browse_button.clicked.connect(self._browse_work_path)
self.work_path_browse_button.setFocusPolicy(Qt.NoFocus)
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)} (не установлен)")
work_path_layout = QHBoxLayout()
work_path_layout.addWidget(self.work_path_edit, 1)
work_path_layout.addWidget(self.work_path_browse_button)
if not self.settings_service.is_work_path_changeable():
self.work_path_edit.setEnabled(False)
self.work_path_browse_button.setEnabled(False)
self.work_path_edit.setToolTip(
"Изменение рабочего каталога доступно только для установленной версии WineHelper."
)
form_layout.addRow(self.work_path_label, work_path_layout)
form_layout.addRow(self.qt_binding_label, self.qt_binding_combo)
layout.addLayout(form_layout)
@@ -99,7 +103,7 @@ class GlobalSettingsWidget(QWidget):
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)
layout.addLayout(button_layout)
@@ -111,69 +115,91 @@ class GlobalSettingsWidget(QWidget):
if index >= 0:
self.log_level_combo.setCurrentIndex(index)
self.work_path_edit.setText(self.settings_service.get_work_path())
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 _browse_work_path(self):
"""Открывает диалог выбора рабочего каталога."""
current_path = self.work_path_edit.text().strip()
directory = QFileDialog.getExistingDirectory(self, "Выбор рабочего каталога", current_path or str(Path.home()))
if directory:
self.work_path_edit.setText(directory)
def _save_work_path(self):
def _save_qt_binding(self):
"""
Сохраняет рабочий каталог при его изменении.
Сохраняет выбранный Qt binding; при смене фактического binding'а
предлагает перезапустить приложение.
Returns:
bool: False если сохранение не удалось (сообщение уже показано).
"""
new_path = self.work_path_edit.text().strip()
if not self.settings_service.is_work_path_changeable() or new_path == self.settings_service.get_work_path():
new_binding = self.qt_binding_combo.currentData()
if new_binding == self.settings_service.get_qt_binding():
return True
answer = QMessageBox.question(
self,
"Изменение рабочего каталога",
f"Рабочий каталог будет изменён на:\n{new_path}\n\n"
"Данные из текущего каталога (префиксы, версии Wine) не переносятся автоматически.\n"
"Изменение вступит в силу после перезапуска WineHelper.\n\nПродолжить?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
if not self.settings_service.set_qt_binding(new_binding):
QMessageBox.warning(self, "Ошибка", "Не удалось сохранить Qt binding.")
return False
success, error = self.settings_service.set_work_path(new_path)
if not success:
QMessageBox.warning(self, "Ошибка", error)
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 _save_settings(self):
"""Сохраняет настройки и применяет уровень логирования."""
if not self._save_work_path():
return
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)
logger.info("Уровень логирования изменён на '%s'", 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()
class GlobalSettingsDialog(QDialog):
"""Модальный диалог глобальных настроек."""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Настройки WineHelper")
self.setMinimumWidth(400)
self.setModal(True)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.settings_widget = GlobalSettingsWidget(self)
layout.addWidget(self.settings_widget)
if not self._save_qt_binding():
return
+42
View File
@@ -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()
+5 -4
View File
@@ -1,4 +1,3 @@
#!/usr/bin/env python3
"""
Модуль вывода предупреждения о noexec для /home.
@@ -9,7 +8,7 @@
import getpass
import sys
from PyQt5.QtWidgets import (
from winehelperqt.qt_compat import (
QApplication,
QDialog,
QTextBrowser,
@@ -88,7 +87,9 @@ class NoExecWarningDialog(QDialog):
<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>
<p style="{paragraph_style} margin-bottom: 0;">
После перезагрузки можно приступать к работе с WineHelper.
</p>
</div>
<div style="line-height: 18px; font-size: 1px;">&nbsp;</div>
@@ -111,7 +112,7 @@ def main() -> int:
app = QApplication(sys.argv)
dialog = NoExecWarningDialog()
dialog.show()
return app.exec_()
return app.exec()
if __name__ == "__main__":
+34
View File
@@ -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
+51 -39
View File
@@ -4,8 +4,8 @@
import os
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
from winehelperqt.logger import get_logger
from winehelperqt.qt_compat import (
QCheckBox,
QComboBox,
QDialog,
@@ -15,10 +15,9 @@ from PyQt5.QtWidgets import (
QMessageBox,
QPushButton,
QSizePolicy,
Qt,
QVBoxLayout,
)
from winehelperqt.logger import get_logger
from winehelperqt.utils.export_utils import ExportUtils
logger = get_logger(__name__)
@@ -29,7 +28,7 @@ class AdditionalSettingsDialog(QDialog):
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("Дополнительные настройки")
@@ -45,9 +44,7 @@ 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)
)
@@ -60,9 +57,7 @@ class AdditionalSettingsDialog(QDialog):
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)
)
@@ -71,8 +66,8 @@ class AdditionalSettingsDialog(QDialog):
)
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")
@@ -87,18 +82,18 @@ class AdditionalSettingsDialog(QDialog):
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")
@@ -115,29 +110,46 @@ class AdditionalSettingsDialog(QDialog):
"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_gui.open_windows_version_manager)
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.NoFocus)
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 через winehelper cpcsp-proxy enable/disable."""
@@ -150,9 +162,9 @@ class AdditionalSettingsDialog(QDialog):
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.run_cpcsp_proxy_disable(self.prefix_name)
self.parent_tab.run_cpcsp_proxy_disable(self.prefix_name)
def _update_button_text(self, button, base_text, is_checked):
"""Обновляет текст кнопки, добавляя или убирая галочку."""
@@ -163,31 +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"
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_gui._get_prefix_component_version(self.prefix_name, "WH_USE_CPCSP_PROXY") == "1"
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):
@@ -195,7 +212,7 @@ class AppAdditionalSettingsDialog(QDialog):
def __init__(self, exe_path, parent=None):
super().__init__(parent)
self.parent_gui = parent
self.parent_tab = parent
self.exe_path = os.path.abspath(exe_path)
self.whdb_path = self.exe_path + ".whdb"
@@ -249,7 +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")
@@ -267,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)
@@ -357,8 +374,3 @@ class AppAdditionalSettingsDialog(QDialog):
def _parse_export_line(line):
"""Парсит строку export VAR=value, игнорируя комментарии и секции."""
return ExportUtils.parse_line(line)
@staticmethod
def _format_export_line(var_name, value):
"""Форматирует строку export VAR=value с экранированием."""
return ExportUtils.format_line(var_name, value)
+1 -1
View File
@@ -2,7 +2,7 @@
Диалог для выбора версии Windows в префиксе Wine.
"""
from PyQt5.QtWidgets import QComboBox, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
from winehelperqt.qt_compat import QComboBox, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
class WindowsVersionSelectionDialog(QDialog):
+33 -16
View File
@@ -5,10 +5,18 @@
import re
from functools import partial
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QGridLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, QTabWidget, QVBoxLayout, QWidget
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
@@ -24,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()
@@ -44,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:
@@ -71,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)
@@ -114,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()
@@ -131,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"))
# Локально установленные версии
@@ -165,7 +178,7 @@ class WineVersionSelectionDialog(WineVersionSelectionDialogBase):
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()
@@ -203,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)
@@ -247,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:
@@ -256,7 +273,7 @@ class WineVersionSelectionDialog(WineVersionSelectionDialogBase):
btn.setToolTip("Эта 32-битная версия Wine несовместима с 64-битным префиксом.")
# Отключение 64-битных версий для 32-битного префикса
if not is_download_tab and is_win32_prefix:
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-битным префиксом.")
@@ -277,7 +294,7 @@ class WineVersionSelectionDialog(WineVersionSelectionDialogBase):
search_text = self.search_edit.text()
# Фильтр для вкладки "Установленные"
if hasattr(self, "installed_grid_layout"):
if self.installed_grid_layout:
GridUtils.filter_buttons(self.installed_buttons, search_text, self.installed_grid_layout, self.NUM_COLUMNS)
# Фильтр для вкладок с версиями (теперь напрямую в main_tabs)
+118
View File
@@ -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
+79 -62
View File
@@ -4,29 +4,33 @@
import os
import shlex
from functools import partial
from PyQt5.QtCore import QProcess, QProcessEnvironment, Qt, QTimer, pyqtSignal
from PyQt5.QtGui import QFont, QTextCharFormat, QTextCursor
from PyQt5.QtWidgets import (
from winehelperqt.logger import get_logger
from winehelperqt.parsers.winetricks_parser import WinetricksOutputParser
from winehelperqt.qt_compat import (
QDialog,
QHBoxLayout,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QProcess,
QPushButton,
Qt,
QTabWidget,
QTextCharFormat,
QTextCursor,
QTextEdit,
QTimer,
QVBoxLayout,
QWidget,
pyqtSignal,
)
from winehelperqt.logger import get_logger
from winehelperqt.parsers.winetricks_parser import WinetricksOutputParser
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),
@@ -41,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)
@@ -103,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)
@@ -164,7 +168,7 @@ class WinetricksManagerDialog(QDialog):
list_widget = NoAutoScrollListWidget()
list_widget.setAutoScroll(False)
list_widget.setTextElideMode(Qt.ElideNone)
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)
@@ -190,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)
@@ -209,19 +213,17 @@ class WinetricksManagerDialog(QDialog):
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.NormalExit, from_cache=cached_output)
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),
@@ -241,23 +243,31 @@ 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]
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.get(category)
if list_widget is None:
@@ -267,16 +277,18 @@ class WinetricksManagerDialog(QDialog):
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)
@@ -302,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()
@@ -319,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
@@ -340,7 +352,7 @@ class WinetricksManagerDialog(QDialog):
if current_list_widget:
current_item = current_list_widget.currentItem()
if current_item:
name = current_item.data(Qt.UserRole)
name = current_item.data(Qt.ItemDataRole.UserRole)
if self.initial_states.get(name, False):
is_reinstallable = True
@@ -360,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
@@ -380,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,7 +435,7 @@ class WinetricksManagerDialog(QDialog):
self._install_queue.append((verbs_to_reinstall, True))
# Запускаем первую задачу, если сейчас ничего не выполняется
if not self.apply_process or self.apply_process.state() != QProcess.Running:
if not self.apply_process or self.apply_process.state() != QProcess.ProcessState.Running:
self._process_next_install_task()
def _process_next_install_task(self):
@@ -458,17 +470,14 @@ class WinetricksManagerDialog(QDialog):
self.apply_process.finished.disconnect(self.on_apply_finished)
except TypeError as e:
logger.debug("Отсутствуют подключенные обработчики процесса winetricks: %s", e)
# Удаляем завершённый процесс, чтобы он не копился
# в диалоге по числу выполненных операций
self.apply_process.deleteLater()
self.apply_process = QProcess(self)
self.apply_process.setProcessChannelMode(QProcess.MergedChannels)
env = QProcessEnvironment.systemEnvironment()
env.insert("WINEPREFIX", self.prefix_path)
self.apply_process.setProcessEnvironment(env)
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(), process=True
)
lambda: self._log(self.apply_process.readAllStandardOutput().data().decode("utf-8", "ignore").strip())
)
self.apply_process.finished.connect(self.on_apply_finished)
@@ -482,14 +491,11 @@ class WinetricksManagerDialog(QDialog):
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)
@@ -534,7 +540,7 @@ 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()
@@ -556,6 +562,7 @@ 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)
@@ -571,41 +578,51 @@ class WinetricksManagerDialog(QDialog):
def closeEvent(self, event):
"""Обрабатывает закрытие окна."""
if self.apply_process and self.apply_process.state() == QProcess.Running:
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:
# Корректно завершаем фоновые процессы (загрузка категорий, очистка кэша)
for process in list(self.processes.values()):
if process.state() == QProcess.Running:
if process.state() == QProcess.ProcessState.Running:
process.terminate()
if self.cache_clear_process and self.cache_clear_process.state() == QProcess.Running:
self.cache_clear_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 _log(self, message, color=None, process=False):
def _log(self, message, color=None):
"""
Добавляет сообщение в лог с возможностью указания цвета.
Args:
message: Текст сообщения
color: Цвет текста (например, "red")
process: True если это вывод внешнего процесса (bash, winetricks)
"""
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():
target_logger = process_logger if process else logger
# Зеркалируем в консоль через логгер процесса: консоль — единое
# зеркало содержимого диалога. Ведущие/хвостовые переносы
# (отступ в виджете) не логируем, иначе префикс строки уйдёт
# на отдельную строку
log_text = message.strip("\n")
if color == "red":
target_logger.error("%s", message)
process_logger.error("%s", log_text)
else:
target_logger.info("%s", message)
process_logger.info("%s", log_text)
+166 -6
View File
@@ -16,10 +16,148 @@
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:
"""
@@ -38,14 +176,36 @@ def setup_logger(level: str | None = None) -> None:
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
logging.basicConfig(
level=log_level,
format="[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s",
datefmt="%H:%M:%S",
handlers=[logging.StreamHandler(sys.stdout)],
)
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)
-11
View File
@@ -1,14 +1,3 @@
"""
Парсеры для winehelper.
"""
from winehelperqt.parsers.script_parser import ScriptParser
from winehelperqt.parsers.sha256_parser import Sha256Parser
from winehelperqt.parsers.winetricks_parser import WinetricksItem, WinetricksOutputParser
__all__ = [
"Sha256Parser",
"WinetricksOutputParser",
"WinetricksItem",
"ScriptParser",
]
+93 -84
View File
@@ -14,99 +14,108 @@ 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)
logger.debug("Парсим скрипт установки: %s", script_path)
try:
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:
icons = icon_names_str.split()
logger.debug("Найдены иконки %s в %s", icons, script_path)
return icons
# Переменные: берём первое вхождение каждой (в т.ч. пустое — как раньше
# в _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:
icons = icon_names_str.split()
logger.debug("Найдены иконки %s в %s", icons, script_path)
return icons
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) as e:
logger.debug("Пропущена строка create_desktop в %s: %s", script_path, e)
continue
logger.debug("Иконки из create_desktop в %s: %s", script_path, icon_names)
return icon_names
except Exception as e:
logger.warning("Не удалось извлечь иконки из %s: %s", script_path, e)
return []
@staticmethod
def _extract_variable(script_path, var_name):
"""Извлекает значение переменной из скрипта установки."""
logger.debug("Извлекаем переменную %s из скрипта: %s", var_name, script_path)
try:
with open(script_path, "r", encoding="utf-8") as f:
for line in f:
parsed_name, parsed_value = ExportUtils.parse_line(line)
if parsed_name == var_name:
logger.debug("Найдена %s=%s в %s", var_name, parsed_value, script_path)
return parsed_value if parsed_value else None
logger.debug("Переменная %s не найдена в %s", var_name, script_path)
return None
except Exception as e:
logger.warning("Не удалось извлечь переменную %s из %s: %s", var_name, script_path, e)
return None
@staticmethod
def extract_prog_name_from_script(script_path):
"""Извлекает имя программы из строки PROG_NAME= в скрипте."""
return ScriptParser._extract_variable(script_path, "PROG_NAME")
@staticmethod
def extract_prog_url_from_script(script_path):
"""Извлекает URL из строки PROG_URL= в скрипте."""
return ScriptParser._extract_variable(script_path, "PROG_URL")
@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:
logger.warning("Не удалось извлечь описание из %s: %s", script_path, e)
return f"Ошибка чтения файла: {str(e)}"
logger.debug("Иконки из create_desktop в %s: %s", script_path, icon_names)
return icon_names
-19
View File
@@ -152,22 +152,3 @@ class WinetricksOutputParser:
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)
+284
View File
@@ -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",
]
-18
View File
@@ -1,21 +1,3 @@
"""
Сервисы для winehelper GUI.
"""
from winehelperqt.services.app_launcher import AppLauncher
from winehelperqt.services.desktop_file_manager import DesktopFileManager
from winehelperqt.services.prefix_manager import PrefixManager
from winehelperqt.services.script_manager import ScriptInfo, ScriptManager
from winehelperqt.services.settings_manager import SettingsManager
from winehelperqt.services.winetricks_service import WinetricksCacheService, WinetricksLogReader
__all__ = [
"WinetricksCacheService",
"WinetricksLogReader",
"PrefixManager",
"DesktopFileManager",
"ScriptManager",
"ScriptInfo",
"AppLauncher",
"SettingsManager",
]
+69 -135
View File
@@ -8,14 +8,13 @@
- Запуск в debug-режиме (WINEDEBUG)
"""
import os
import shlex
from functools import partial
from typing import Dict, Optional
from PyQt5.QtCore import QProcess, QProcessEnvironment
from winehelperqt.dialogs.winedebug_base import DEFAULT_WINEDEBUG
from winehelperqt.logger import get_logger
from winehelperqt.utils.desktop_entry import DesktopEntry
from winehelperqt.qt_compat import QProcess, QWidget
from winehelperqt.utils.process_utils import ProcessUtils
logger = get_logger(__name__)
@@ -36,56 +35,13 @@ 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}")
self._validate_desktop_origin(desktop_path)
# Парсим .desktop файл
exec_command = self._get_exec_from_desktop(desktop_path)
if not exec_command:
raise ValueError(f"Не удалось получить команду запуска из: {desktop_path}")
args = shlex.split(exec_command)
return self._start_process(desktop_path, args, debug=debug, connect_finished=True)
def _validate_desktop_origin(self, desktop_path: str) -> None:
"""
Проверяет, что .desktop файл находится в доверенной директории.
Args:
desktop_path: Путь к .desktop файлу
Raises:
ValueError: если файл находится вне доверенных расположений
"""
desktop_dir = os.path.dirname(os.path.abspath(desktop_path))
allowed_dirs = [
os.path.abspath(self.user_work_path),
os.path.abspath(os.path.expanduser("~/.local/share/applications/WineHelper")),
]
for allowed_dir in allowed_dirs:
try:
if os.path.commonpath([desktop_dir, allowed_dir]) == allowed_dir:
return
except ValueError as e:
logger.debug("Путь %s не относится к разрешённому %s: %s", desktop_dir, allowed_dir, e)
continue
raise ValueError(f"Desktop файл из недоверенного расположения: {desktop_path}")
def launch_app_by_command(
self, desktop_path: str, command: list, env_vars: Optional[Dict[str, str]] = None, debug: bool = False
self,
desktop_path: str,
command: list,
env_vars: Optional[Dict[str, str]] = None,
debug: bool = False,
parent: Optional[QWidget] = None,
) -> QProcess:
"""
Запускает приложение по команде (для сложных случаев с env).
@@ -95,9 +51,14 @@ class AppLauncher:
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}")
@@ -105,7 +66,14 @@ class AppLauncher:
if not command:
raise ValueError("Команда запуска не может быть пустой")
return self._start_process(desktop_path, command, env_vars=env_vars, debug=debug, connect_finished=False)
return self._start_process(
desktop_path,
command,
env_vars=env_vars,
debug=debug,
connect_finished=False,
parent=parent,
)
def _start_process(
self,
@@ -114,49 +82,50 @@ class AppLauncher:
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: Список аргументов команды
desktop_path: Путь к .desktop файлу (для отслеживания)
command: Список аргументов команды запуска
env_vars: Дополнительные переменные окружения
debug: Режим отладки
connect_finished: Подключать ли обработчик завершения
parent: Родительский виджет для QProcess, чтобы deleteLater
гарантированно удалял C++-объект
Returns:
QProcess запущенного приложения
"""
process = QProcess()
process.setProcessChannelMode(QProcess.MergedChannels)
process = QProcess(parent)
env = QProcessEnvironment.systemEnvironment()
if env_vars:
for key, value in env_vars.items():
env.insert(key, value)
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)
executable = command[0]
logged_env = {key: env_vars[key] for key in (env_vars or {})}
if debug:
logged_env["WINEDEBUG"] = "+loaddll,+seh"
logger.debug(
"Запуск процесса: %s %s (env=%s)",
shlex.quote(executable),
" ".join(shlex.quote(a) for a in command[1:]),
logged_env,
env_vars,
)
process.start(executable, command[1:])
if not process.waitForStarted(5000):
logger.error("Не удалось дождаться старта приложения: %s", desktop_path)
raise RuntimeError(f"Не удалось запустить приложение: {desktop_path}")
# Регистрируем процесс сразу: вызывающий код получит его через
# 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)
@@ -164,6 +133,29 @@ class AppLauncher:
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]:
"""
Отмечает приложение как завершённое и возвращает процесс.
@@ -176,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:
"""
Проверяет, запущено ли приложение.
@@ -198,7 +179,7 @@ class AppLauncher:
True если приложение запущено
"""
process = self._active_processes.get(desktop_path)
return process is not None and process.state() == QProcess.Running
return process is not None and process.state() == QProcess.ProcessState.Running
def get_running_apps(self) -> Dict[str, QProcess]:
"""
@@ -207,7 +188,9 @@ class AppLauncher:
Returns:
Словарь {desktop_path: QProcess}
"""
return {path: proc for path, proc in self._active_processes.items() if proc.state() == QProcess.Running}
return {
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]:
"""
@@ -221,57 +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, exit_code: int = 0) -> None:
"""Обработчик завершения приложения."""
self._active_processes.pop(desktop_path, None)
if exit_code != 0:
logger.warning("Приложение %s завершилось с кодом %d", desktop_path, exit_code)
def _get_exec_from_desktop(self, desktop_path: str) -> Optional[str]:
"""
Получает команду запуска из .desktop файла.
Args:
desktop_path: Путь к .desktop файлу
Returns:
Команда запуска или None
"""
try:
entry = DesktopEntry(desktop_path)
entry.load()
exec_cmd = entry.get("Desktop Entry", "Exec")
if not exec_cmd:
return None
# Убираем %F, %f, %U, %u (параметры desktop-файла)
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:
logger.error("Ошибка чтения %s: %s", desktop_path, e)
return None
+238
View File
@@ -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)
@@ -4,7 +4,6 @@
import os
import shlex
import shutil
from winehelperqt.logger import get_logger
from winehelperqt.utils.desktop_entry import DesktopEntry
@@ -90,67 +89,6 @@ class DesktopFileManager:
logger.warning("Не удалось прочитать .desktop файл %s: %s", desktop_path, e)
return {}
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)
logger.debug("Удалён .desktop файл %s", desktop_path)
return True, "Файл удален"
except OSError as e:
logger.error("Не удалось удалить .desktop файл %s: %s", desktop_path, 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))
@staticmethod
def get_exe_path_from_exec(exec_line, base_dir=None):
"""
@@ -216,28 +154,3 @@ class DesktopFileManager:
# Сортируем по отображаемому имени, чтобы порядок кнопок был стабильным
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)
logger.debug("Скопирован .desktop файл %s -> %s", source_path, dest_path)
return True, "Файл скопирован"
except (IOError, OSError) as e:
logger.error("Не удалось скопировать .desktop файл %s -> %s: %s", source_path, dest_path, e)
return False, f"Ошибка копирования: {e}"
@@ -10,7 +10,6 @@ import os
from pathlib import Path
from winehelperqt.logger import get_logger
from winehelperqt.utils.environment import EnvironmentConfig
logger = get_logger(__name__)
@@ -24,6 +23,9 @@ class GlobalSettingsService:
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):
"""
Инициализирует сервис настроек.
@@ -97,68 +99,40 @@ class GlobalSettingsService:
logger.debug("Уровень логирования сохранён: %s", level)
return True
@staticmethod
def is_work_path_changeable():
def get_qt_binding(self):
"""
Проверяет, можно ли изменить рабочий каталог.
Рабочий каталог меняется только для установленного в систему
WineHelper (CLI экспортирует WH_CONFIG); в режиме разработки
путь фиксирован скриптом winehelper.
Возвращает выбранный Qt binding из конфигурации.
Returns:
bool: True если изменение поддерживается.
str: один из auto/pyqt6/pyqt5.
"""
return bool(os.environ.get("WH_CONFIG"))
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 get_work_path(self):
def set_qt_binding(self, binding):
"""
Возвращает текущий рабочий каталог WineHelper.
Сохраняет выбранный Qt binding в конфигурации.
Returns:
str: путь из окружения (USER_WORK_PATH) или пустая строка.
"""
return EnvironmentConfig.USER_WORK_PATH or ""
def set_work_path(self, path):
"""
Сохраняет новый рабочий каталог в конфиг CLI (~/.config/winehelper/winehelper.conf).
Аналог команды `winehelper data-path /путь/к/каталогу`.
Проверяет доступность каталога на чтение-запись (создаёт его при
необходимости), затем записывает export USER_WORK_PATH в WH_CONFIG.
Изменение применяется при следующем запуске WineHelper.
Применяется при следующем запуске GUI: winehelperqt/qt_compat.py
читает этот ключ при импорте (переменная WH_QT_BINDING имеет приоритет).
Args:
path: новый рабочий каталог.
binding: auto/pyqt6/pyqt5.
Returns:
tuple: (bool, str) — успех и сообщение об ошибке (пустая строка при успехе).
bool: True если значение корректно и сохранено.
"""
path = str(path).strip()
if not path:
return False, "Укажите путь к рабочему каталогу."
binding = binding.lower().strip()
if binding not in self.VALID_QT_BINDINGS:
logger.error("Попытка сохранить некорректный Qt binding: %s", binding)
return False
wh_config = os.environ.get("WH_CONFIG")
if not wh_config:
return False, "Изменение рабочего каталога доступно только для установленной версии WineHelper."
try:
os.makedirs(path, exist_ok=True)
except OSError as e:
logger.error("Не удалось создать рабочий каталог %s: %s", path, e)
return False, f"Не удалось создать каталог: {e}"
if not (os.access(path, os.R_OK) and os.access(path, os.W_OK)):
return False, f"Нет доступа на чтение-запись в каталог: {path}"
try:
os.makedirs(os.path.dirname(wh_config), exist_ok=True)
with open(wh_config, "w", encoding="utf-8") as f:
f.write(f'export USER_WORK_PATH="{path}"\n')
except OSError as e:
logger.error("Не удалось записать конфиг %s: %s", wh_config, e)
return False, f"Не удалось сохранить настройку: {e}"
logger.info("Рабочий каталог изменён на: %s (применится после перезапуска)", path)
return True, ""
config = self._load_config()
config["gui"]["qt_binding"] = binding
self._save_config(config)
logger.debug("Qt binding сохранён: %s", binding)
return True
+242
View File
@@ -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
-99
View File
@@ -3,8 +3,6 @@
"""
import os
import re
import shutil
import subprocess
from winehelperqt.logger import get_logger
@@ -60,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):
"""
Возвращает список всех префиксов.
@@ -96,59 +82,6 @@ class PrefixManager:
logger.warning("Не удалось прочитать директорию префиксов %s: %s", self.prefixes_path, e)
return []
def create_prefix(self, prefix_name, wine_arch="win64", base_pfx="none", windows_version="10"):
"""
Создает новый префикс.
Args:
prefix_name: Имя префикса
wine_arch: Архитектура ("win32" или "win64")
base_pfx: Базовый шаблон префикса
windows_version: Версия Windows, сообщаемая приложениям
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)
logger.debug("Создан префикс: %s", prefix_path)
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)
logger.debug("Удалён префикс: %s", prefix_path)
return True, "Префикс удален"
except OSError as e:
return False, f"Ошибка удаления префикса: {e}"
def read_prefix_config(self, prefix_name, config_file="last.conf"):
"""
Читает конфигурацию префикса.
@@ -212,20 +145,6 @@ class PrefixManager:
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):
"""
Открывает папку префикса в файловом менеджере.
@@ -248,21 +167,3 @@ class PrefixManager:
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("WINEARCH", "unknown")
result.append((prefix_name, arch))
logger.debug("Префиксы с архитектурой: %s", result)
return result
+6 -172
View File
@@ -31,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
@@ -59,73 +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 as e:
logger.warning("Не удалось прочитать папку со скриптами %s: %s", folder_path, e)
return []
def get_script_files(self, folder_name: str) -> List[str]:
"""
Возвращает список файлов скриптов из папки.
Поддерживает плоскую структуру проекта, где скрипты хранятся
непосредственно в папках autoinstall/, manualinstall/, testinstall/.
Args:
folder_name: Имя папки (autoinstall, manualinstall, testinstall)
Returns:
Список путей к файлам скриптов
"""
folder_path = os.path.join(self.data_path, folder_name)
if not os.path.isdir(folder_path):
logger.debug("Папка со скриптами не найдена: %s", folder_path)
return []
try:
entries = [os.path.join(folder_path, entry) for entry in os.listdir(folder_path)]
logger.debug("Найдено %d записей в %s", len(entries), folder_path)
def _is_valid_script(path: str) -> bool:
if not os.path.isfile(path):
return False
basename = os.path.basename(path)
if basename.startswith("."):
return False
if basename.endswith(".swp") or basename.endswith("~"):
return False
return True
scripts = sorted([entry for entry in entries if _is_valid_script(entry)])
logger.debug("Отфильтровано скриптов в %s: %d", folder_path, len(scripts))
return scripts
except OSError as e:
logger.warning("Не удалось прочитать папку со скриптами %s: %s", folder_path, e)
return []
def get_script_info(self, script_path: str) -> Optional[ScriptInfo]:
"""
Получает информацию о скрипте установки.
@@ -152,11 +76,12 @@ class ScriptManager:
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)
@@ -182,97 +107,6 @@ class ScriptManager:
return script_info
def list_available_apps(self, folder_name: str) -> List[ScriptInfo]:
"""
Возвращает список доступных приложений из папки.
Args:
folder_name: Имя папки со скриптами
Returns:
Список ScriptInfo
"""
script_files = self.get_script_files(folder_name)
logger.debug("Загружаем скрипты из %s: найдено файлов %d", folder_name, len(script_files))
apps = []
for script_file in script_files:
info = self.get_script_info(script_file)
if info:
apps.append(info)
logger.debug("Загружено приложений из %s: %d", folder_name, len(apps))
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()
def _matches(script: ScriptInfo) -> bool:
if search_lower in script.name.lower():
return True
return search_lower in script.description.lower()
return [script for script in scripts if _matches(script)]
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:
"""
Создаёт ключ кэша для скрипта на основе пути и времени модификации.
+1 -160
View File
@@ -8,13 +8,9 @@
- Загрузка настроек по умолчанию
"""
import os
import shutil
from datetime import datetime
from typing import Dict, Optional
from typing import Dict
from winehelperqt.logger import get_logger
from winehelperqt.utils.desktop_entry import DesktopEntry
logger = get_logger(__name__)
@@ -66,158 +62,3 @@ class SettingsManager:
"""
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 {}
try:
entry = DesktopEntry(desktop_path)
entry.load()
settings = dict(entry.section("Desktop Entry"))
logger.debug("Загружены настройки приложения %s: %s", desktop_path, settings)
return settings
except (IOError, OSError) as e:
logger.warning("Не удалось прочитать настройки приложения %s: %s", desktop_path, e)
return {}
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:
entry = DesktopEntry(desktop_path)
entry.load()
desktop_section = entry.section("Desktop Entry")
desktop_section.update(settings)
return entry.save()
except (IOError, OSError) as e:
logger.error("Ошибка сохранения настроек: %s", 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)
logger.debug("Создан бэкап настроек: %s -> %s", source_path, backup_path)
return backup_path
except (IOError, OSError) as e:
logger.error("Ошибка создания бэкапа: %s", e)
return None
def restore_settings(self, backup_path: str, target_path: str) -> bool:
"""
Восстанавливает настройки из бэкапа.
Args:
backup_path: Путь к файлу бэкапа
target_path: Путь для восстановления
Returns:
True если восстановление успешно
"""
logger.debug("Восстановление настроек из бэкапа: %s -> %s", backup_path, target_path)
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:
logger.error("Ошибка восстановления настроек: %s", e)
return False
def get_default_settings(self, prefix_name: str) -> Dict[str, str]:
"""
Возвращает настройки по умолчанию для префикса.
Args:
prefix_name: Имя префикса
Returns:
Словарь с настройками по умолчанию
"""
defaults = {
"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",
}
logger.debug("Настройки по умолчанию для %s: %s", prefix_name, defaults)
return defaults
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)
logger.debug("Объединение настроек: база=%s, переопределения=%s, результат=%s", base, override, result)
return result
@@ -110,24 +110,6 @@ class WinetricksCacheService:
logger.debug("Кэш категории winetricks '%s' сохранён", category)
return saved
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)
class WinetricksLogReader:
"""
@@ -151,18 +133,3 @@ class WinetricksLogReader:
verbs = WinetricksOutputParser.parse_installed_verbs(self.log_path)
logger.debug("Установленные компоненты winetricks (%s): %d шт.", self.log_path, len(verbs))
return verbs
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)
-18
View File
@@ -1,21 +1,3 @@
"""
Утилиты для winehelper GUI.
"""
from winehelperqt.utils.cache_service import CacheService, FileCacheService
from winehelperqt.utils.dialog_utils import DialogUtils
from winehelperqt.utils.export_utils import ExportUtils
from winehelperqt.utils.grid_utils import GridUtils
from winehelperqt.utils.log_handler import LogProgressHandler, MultiLogHandler
from winehelperqt.utils.version_utils import sort_versions
__all__ = [
"LogProgressHandler",
"MultiLogHandler",
"CacheService",
"FileCacheService",
"sort_versions",
"ExportUtils",
"DialogUtils",
"GridUtils",
]
-124
View File
@@ -106,46 +106,6 @@ class CacheService:
self._cache[key] = CacheEntry(value, effective_ttl)
logger.debug("Кэш '%s': сохранено (TTL %s)", key, 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)
def _evict_expired(self) -> None:
"""Удаляет все истекшие записи."""
expired_keys = [key for key, entry in self._cache.items() if entry.is_expired()]
@@ -160,17 +120,6 @@ class CacheService:
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:
"""Сервис файлового кэширования для постоянных данных."""
@@ -273,76 +222,3 @@ class FileCacheService:
except (IOError, OSError) as 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)
logger.debug("Файловый кэш '%s': удалён", key)
return True
except OSError as e:
logger.warning("Не удалось удалить файловый кэш '%s': %s", key, e)
return False
def clear(self) -> bool:
"""
Очищает весь файловый кэш.
Returns:
True если очистка успешна
"""
try:
if self.cache_dir.exists():
count = 0
for file_path in self.cache_dir.glob("*.cache"):
file_path.unlink()
count += 1
logger.debug("Файловый кэш очищен: удалено %d файлов", count)
return True
except (IOError, OSError) as e:
logger.error("Ошибка очистки кэша: %s", 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) as e:
logger.debug("Не удалось определить возраст кэша '%s': %s", key, e)
return None
def is_valid(self, key: str) -> bool:
"""
Проверяет актуальность кэша.
Args:
key: Ключ
Returns:
True если кэш актуален
"""
return self.get_json(key) is not None
-32
View File
@@ -89,31 +89,10 @@ class DesktopEntry:
return self
def save(self) -> bool:
"""Сохраняет .desktop файл."""
try:
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
with open(self.path, "w", encoding="utf-8") as f:
for section_name, entries in self.sections.items():
f.write(f"[{section_name}]\n")
for key, value in entries.items():
f.write(f"{key}={self._escape(value)}\n")
f.write("\n")
return True
except (IOError, OSError) as e:
logger.error("Ошибка записи .desktop файла %s: %s", self.path, e)
return False
def get(self, section: str, key: str, default=None):
"""Возвращает значение ключа из секции."""
return self.sections.get(section, OrderedDict()).get(key, default)
def set(self, section: str, key: str, value: str):
"""Устанавливает значение ключа в секции."""
if section not in self.sections:
self.sections[section] = OrderedDict()
self.sections[section][key] = value
def section(self, section: str) -> "OrderedDict[str, str]":
"""Возвращает секцию как OrderedDict."""
return self.sections.setdefault(section, OrderedDict())
@@ -133,14 +112,3 @@ class DesktopEntry:
result.append(value[i])
i += 1
return "".join(result)
@classmethod
def _escape(cls, value: str) -> str:
"""Экранирует спецсимволы для .desktop."""
result = []
for char in value:
if char in cls._REVERSE_ESCAPE_MAP:
result.append(cls._REVERSE_ESCAPE_MAP[char])
else:
result.append(char)
return "".join(result)
+29 -12
View File
@@ -1,14 +1,31 @@
"""
Утилиты для создания стандартных диалогов.
Утилиты для работы с диалогами и виджетами.
Устраняет дублирование кода подтверждающих QMessageBox в GUI.
Устраняет дублирование кода подтверждающих QMessageBox и проверок
живости виджетов в GUI.
"""
from PyQt5.QtWidgets import QMessageBox, QPushButton, QWidget
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(
@@ -18,7 +35,7 @@ class DialogUtils:
yes_text: str = "Да",
no_text: str = "Нет",
default_no: bool = True,
icon: QMessageBox.Icon = QMessageBox.Question,
icon: QMessageBox.Icon = QMessageBox.Icon.Question,
) -> bool:
"""
Показывает подтверждающий диалог с двумя кнопками.
@@ -43,15 +60,15 @@ class DialogUtils:
yes_button = QPushButton(yes_text)
no_button = QPushButton(no_text)
msg_box.addButton(yes_button, QMessageBox.YesRole)
msg_box.addButton(no_button, QMessageBox.NoRole)
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_()
msg_box.exec()
return msg_box.clickedButton() == yes_button
@staticmethod
@@ -78,14 +95,14 @@ class DialogUtils:
msg_box = QMessageBox(parent)
msg_box.setWindowTitle(title)
msg_box.setText(text)
msg_box.setIcon(QMessageBox.Question)
msg_box.setIcon(QMessageBox.Icon.Question)
yes_button = QPushButton(yes_text)
no_button = QPushButton(no_text)
msg_box.addButton(yes_button, QMessageBox.DestructiveRole)
msg_box.addButton(no_button, QMessageBox.RejectRole)
msg_box.addButton(yes_button, QMessageBox.ButtonRole.DestructiveRole)
msg_box.addButton(no_button, QMessageBox.ButtonRole.RejectRole)
msg_box.setDefaultButton(no_button)
msg_box.exec_()
msg_box.exec()
return msg_box.clickedButton() == yes_button
+2 -5
View File
@@ -1,7 +1,7 @@
"""
Единый модуль для переменных окружения WineHelper.
Устраняет дублирование класса Var в нескольких файлах.
Устраняет дублирование конфигурации окружения в нескольких файлах.
"""
import os
@@ -24,10 +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" при тестовом запуске из репозитория
# Константы GUI
WH_BACKUP_LOG_DIR = os.path.join(os.path.expanduser("~"), "winehelper_backup_log")
# Alias
Var = EnvironmentConfig
+25
View File
@@ -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
+55 -3
View File
@@ -5,9 +5,31 @@
в списках приложений и диалогах выбора версий.
"""
from dataclasses import dataclass
from typing import Iterable, Optional
from PyQt5.QtWidgets import QAbstractButton, QFrame, QGridLayout, QWidget
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:
@@ -15,6 +37,36 @@ class GridUtils:
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,
@@ -55,7 +107,7 @@ class GridUtils:
if grid_layout is not None:
for i, btn in enumerate(visible_buttons):
row, col = divmod(i, num_columns)
grid_layout.addWidget(btn, row, col)
cls._move_to_position(grid_layout, btn, row, col)
return visible_buttons
@@ -102,6 +154,6 @@ class GridUtils:
for i, frame in enumerate(visible_frames):
row, col = divmod(i, num_columns)
grid_layout.addWidget(frame, row, col)
cls._move_to_position(grid_layout, frame, row, col)
return visible_frames
+48 -73
View File
@@ -5,9 +5,9 @@
import re
from PyQt5.QtGui import QTextCursor
from winehelperqt.logger import get_logger
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),
@@ -24,6 +24,11 @@ class LogProgressHandler:
"""
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):
"""
@@ -65,18 +70,6 @@ class LogProgressHandler:
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):
"""
Завершает обработку лога.
@@ -85,12 +78,19 @@ 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()
@@ -113,26 +113,44 @@ class LogProgressHandler:
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)
# Дублируем в консоль (строки прогресса пропускаем, чтобы не спамить процентами)
if not is_progress_line:
# Зеркалируем в консоль: прогресс — строкой лога с префиксом
# с анимацией (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):
"""
Добавляет сообщение в лог.
@@ -146,7 +164,7 @@ class LogProgressHandler:
return
cursor = self.log_widget.textCursor()
cursor.movePosition(QTextCursor.End)
cursor.movePosition(QTextCursor.MoveOperation.End)
# Если предыдущий текст был добавлен через QTextEdit.append() или
# процессом без завершающего переноса, курсор окажется в конце блока.
@@ -170,33 +188,6 @@ class LogProgressHandler:
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)
logger.error("%s", text)
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)
logger.info("%s", text)
class MultiLogHandler:
"""
@@ -236,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()
+6 -16
View File
@@ -7,9 +7,7 @@
import html
import os
from winehelperqt.logger import get_logger
logger = get_logger(__name__)
from winehelperqt.parsers.winetricks_parser import WinetricksOutputParser
# Служебные компоненты winetricks, которые не показываем пользователю
VERBS_TO_IGNORE = {
@@ -39,6 +37,7 @@ DISPLAY_MAP = {
"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 "Не задана"),
}
@@ -54,6 +53,7 @@ DISPLAY_ORDER = [
"WINEESYNC",
"WINEFSYNC",
"WH_USE_CPCSP_PROXY",
"WH_USE_GRDWINE",
"WH_WINDOWS_VER",
"WH_XDG_OPEN",
]
@@ -67,21 +67,11 @@ def _read_winetricks_verbs(prefix_dir):
prefix_dir: Путь к директории префикса
Returns:
Отсортированный список компонентов для отображения
Отсортированный список компонентов для отображения (без служебных)
"""
log_path = os.path.join(prefix_dir, "winetricks.log")
installed_verbs = []
if os.path.exists(log_path):
try:
with open(log_path, "r", encoding="utf-8") as f:
for line in f:
verb = line.split("#", 1)[0].strip()
if verb:
installed_verbs.append(verb)
except IOError as e:
logger.error("Ошибка чтения winetricks.log: %s", e)
return sorted([v for v in installed_verbs if v not in VERBS_TO_IGNORE])
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):
+20 -16
View File
@@ -4,7 +4,7 @@
Устраняет дублирование кода остановки процессов в разных частях приложения.
"""
from PyQt5.QtCore import QProcess, QTimer
from winehelperqt.qt_compat import QProcess, QProcessEnvironment, QTimer
class ProcessUtils:
@@ -13,6 +13,23 @@ 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:
"""
@@ -26,7 +43,7 @@ class ProcessUtils:
if not process:
return
if process.state() == QProcess.Running:
if process.state() == QProcess.ProcessState.Running:
process.terminate()
QTimer.singleShot(timeout, lambda p=process: ProcessUtils._kill_if_running(p))
@@ -34,20 +51,7 @@ class ProcessUtils:
def _kill_if_running(process: QProcess) -> None:
"""Убивает процесс, если он не завершился после terminate."""
try:
if process and process.state() == QProcess.Running:
if process and process.state() == QProcess.ProcessState.Running:
process.kill()
except RuntimeError:
pass # объект QProcess уже удалён
@staticmethod
def is_running(process: QProcess) -> bool:
"""
Проверяет, запущен ли процесс.
Args:
process: Процесс для проверки
Returns:
True если процесс запущен
"""
return process is not None and process.state() == QProcess.Running
-6
View File
@@ -1,9 +1,3 @@
"""
Виджеты для winehelper GUI.
"""
from winehelperqt.widgets.icon_manager import IconManager
__all__ = [
"IconManager",
]
+202
View File
@@ -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;">&bull; {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>'
"ООО &quot;Базальт СПО&quot;<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"&nbsp;&nbsp;&nbsp;&nbsp;"
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
+69 -44
View File
@@ -12,9 +12,22 @@
import os
from typing import Dict, List, Optional, Tuple
from PyQt5.QtCore import QEasingCurve, QObject, QPropertyAnimation, QSize, Qt, QTimer
from PyQt5.QtGui import QColor, QIcon, QPainter, QPixmap
from PyQt5.QtWidgets import QFrame, QSizePolicy, QToolButton, QVBoxLayout
from winehelperqt.qt_compat import (
QColor,
QEasingCurve,
QFrame,
QIcon,
QObject,
QPainter,
QPixmap,
QPropertyAnimation,
QSize,
QSizePolicy,
Qt,
QTimer,
QToolButton,
QVBoxLayout,
)
class IconManager(QObject):
@@ -52,7 +65,6 @@ class IconManager(QObject):
"""
super().__init__(parent)
self._icon_animators: Dict[QToolButton, Dict] = {}
self._icon_cache: Dict[str, QIcon] = {}
def create_animated_button(
self,
@@ -81,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))
@@ -140,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) и применяет указанный цвет.
@@ -156,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:
"""
Устанавливает стиль выделения для рамки кнопки.
@@ -215,11 +221,30 @@ class IconManager(QObject):
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:
"""
@@ -243,14 +268,14 @@ class IconManager(QObject):
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
# Останавливаем предыдущую анимацию
if animation.state() == QPropertyAnimation.Running:
if animation.state() == QPropertyAnimation.State.Running:
animation.stop()
# Определяем текущую и следующую иконки
@@ -293,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)
+104
View File
@@ -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)
+425
View File
@@ -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,
),
)
+813
View File
@@ -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
+19
View File
@@ -0,0 +1,19 @@
"""
Отображаемые имена вкладок главного окна WineHelper.
Единый источник строк для логики переключения вкладок: сравнения в
обработчиках и вызовах add_tab используют только эти константы,
чтобы переименование вкладки не ломало поведение молча.
"""
class TabNames:
"""Имена вкладок главного окна (как они отображаются в TabBar)."""
AUTO_INSTALL = "Автоматическая установка"
MANUAL_INSTALL = "Ручная установка"
EXTERNAL_INSTALL = "Сторонняя установка"
INSTALLED = "Установленные"
PREFIX_MANAGER = "Менеджер префиксов"
SETTINGS = "Настройки"
HELP = "Справка"