Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
266302d643 | ||
|
|
38d3a43c03 | ||
|
|
c2c5a05013 | ||
|
|
410b24c9b5 | ||
|
|
a525b9f749 | ||
|
|
3c856cd890 | ||
|
|
c2bf75d0da | ||
|
|
50a2498abb | ||
|
|
53ec0f88bd | ||
|
|
b8cebe18b7 | ||
|
|
fe4473ef76 | ||
|
|
7c53112cc9 | ||
|
|
453b5a5a99 | ||
|
|
4990ac941a | ||
|
|
656e20eaa1 | ||
|
|
72ccbaa747 | ||
|
|
e4bafa7a1f | ||
|
|
271fb70d74 | ||
|
|
0884034a31 | ||
|
|
fe37c07729 | ||
|
|
5f506223ac | ||
|
|
0590170cd2 | ||
|
|
ae4eed8be9 | ||
|
|
0231f7fe3f | ||
|
|
e4e7828420 | ||
|
|
41ec383343 | ||
|
|
3591f657f9 | ||
|
|
db88a6ebd0 | ||
|
|
691f13b046 | ||
|
|
b865533a4b | ||
|
|
df22c2b1db | ||
|
|
3bf33340b9 | ||
|
|
1c221ed1cc | ||
|
|
5fa4514665 | ||
|
|
a5e339b112 | ||
|
|
b33f476fc9 | ||
|
|
22d76d4bea | ||
|
|
6a5aeed900 | ||
|
|
b462a4729c | ||
|
|
fb79aeaddb | ||
|
|
47fbff3a0f | ||
|
|
e6051aa04b | ||
|
|
552c118595 | ||
|
|
906ad8cae6 | ||
|
|
cb6255454a | ||
|
|
daf0758d75 | ||
|
|
c8deeb1b8a | ||
|
|
9a8c185846 | ||
|
|
66f11f384d | ||
|
|
e1701fbac9 | ||
|
|
e6717908ac | ||
|
|
68c665fb8e | ||
|
|
d83ef56e0e | ||
|
|
261b70fbe0 | ||
|
|
b76c6f25f5 | ||
|
|
5e22af5b4b | ||
|
|
f84e8a9de3 | ||
|
|
422a22562f | ||
|
|
1caa6e2fc1 | ||
|
|
547a705e37 | ||
|
|
8d69318f11 | ||
|
|
e68636758c | ||
|
|
1f94dfd0e2 | ||
|
|
50bd638ad7 |
No files matched your search
@@ -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 ""`.
|
||||
|
||||
## Безопасность
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# info_ru: Это мощный, современный инструмент быстрой и качественной разработки АСУТП
|
||||
########################################################################
|
||||
export PROG_URL="https://www.krug2000.ru"
|
||||
export WH_WINE_USE="wine-11.12-alt-sk2000-i586"
|
||||
export WINEPREFIX="scada-krug-2000"
|
||||
export PROG_NAME="SCADA КРУГ 2000"
|
||||
export PROG_ICON="krugtaskmanager"
|
||||
export BASE_PFX="scadakrug2k_pfx_v01"
|
||||
export WH_WINDOWS_VER="10"
|
||||
export WINEARCH="win32"
|
||||
export INSTALL_DLL="dotnet48 vcrun6 vcrun2008 vcrun2022 d3dcompiler_47 mdac28 corefonts riched20 wsh57"
|
||||
|
||||
KRUG_VERSION="4_4"
|
||||
AUTOINSTALL_EXE="${WH_TMP_DIR}/01_scada_v${KRUG_VERSION}_release.zip"
|
||||
AUTOINSTALL_UNPACK="${WH_TMP_DIR}/scada_krug_${KRUG_VERSION}"
|
||||
|
||||
prepair_wine
|
||||
if try_download "https://www.krug2000.ru/sp/01_scada_v${KRUG_VERSION}_release.zip" "${AUTOINSTALL_EXE}"; then
|
||||
unpack "${AUTOINSTALL_EXE}" "${AUTOINSTALL_UNPACK}"
|
||||
try_remove_file "$AUTOINSTALL_EXE"
|
||||
|
||||
# Корневой setup.exe в архиве — HTML-меню автозапуска, тихую установку не поддерживает (bug #59578).
|
||||
# Настоящий инсталлятор — DeveloperStudio/SCADA KRUG 2000.msi, ставим его напрямую.
|
||||
AUTOINSTALL_SETUP="$(find "$AUTOINSTALL_UNPACK" -iname "SCADA KRUG 2000.msi" -print -quit)"
|
||||
[[ -f "$AUTOINSTALL_SETUP" ]] || fatal "В архиве дистрибутива не найден файл SCADA KRUG 2000.msi."
|
||||
|
||||
wine_run_install "${AUTOINSTALL_SETUP}" /qn
|
||||
# Инсталлятор оставляет запущенными KrugTaskManager.exe, SMTListener.exe, KrugTaskStarter.exe
|
||||
"$WINESERVER" -k
|
||||
try_remove_dir "$AUTOINSTALL_UNPACK"
|
||||
|
||||
create_desktop "Менеджер задач" "$DRIVE_C/Program Files/Krug2000/Bin/KrugTaskManager.exe" "$PROG_ICON"
|
||||
fi
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# info_ru: ZET 9 Lite — бесплатная астрологическая программа: натальные карты, транзиты, прогнозы, расчёты по швейцарским эфемеридам.
|
||||
########################################################################
|
||||
export PROG_URL="https://astrozet.net"
|
||||
export WH_WINE_USE="wine-11.12-alt-tkg-wow64"
|
||||
export WINEPREFIX="zet9"
|
||||
export PROG_NAME="ZET 9 Lite"
|
||||
export PROG_ICON="zet9"
|
||||
export BASE_PFX="none"
|
||||
export WINEARCH="win64"
|
||||
export WH_WINDOWS_VER="10"
|
||||
|
||||
AUTOINSTALL_URL="https://astrozet.net/upload/ZET9SETUP229-RU.exe"
|
||||
AUTOINSTALL_EXE="${WH_TMP_DIR}/ZET9SETUP229-RU.exe"
|
||||
|
||||
prepair_wine
|
||||
if try_download "$AUTOINSTALL_URL" "$AUTOINSTALL_EXE" ; then
|
||||
wine_run_install "$AUTOINSTALL_EXE" /S
|
||||
try_remove_file "$AUTOINSTALL_EXE"
|
||||
|
||||
create_desktop "$PROG_NAME" "$DRIVE_C/ZET 9/zet.exe" "$PROG_ICON"
|
||||
fi
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# info_ru: ADEM 9.0 — интегрированная система CAD/CAM/CAPP/PDM для конструкторско-технологической подготовки производства. Ручная установка из дистрибутива CADCAMCAPPAdem90st.exe.
|
||||
########################################################################
|
||||
export PROG_URL="https://adem.ru"
|
||||
export WH_WINE_USE="wine-11.18-alt-adem-wow64"
|
||||
export WINEPREFIX="adem"
|
||||
export PROG_NAME="ADEM 9.0"
|
||||
export PROG_ICON="adem"
|
||||
export BASE_PFX="adem_pfx_x64_v02"
|
||||
export WINEARCH="win64"
|
||||
export WH_WINDOWS_VER="10"
|
||||
export INSTALL_DLL="dotnet48 vcrun2008 vcrun2010 vcrun2012 vcrun2022 corefonts"
|
||||
|
||||
if [[ -f "$2" ]] \
|
||||
&& [[ $(basename "$2") =~ ^CADCAMCAPPAdem.*\.exe$ ]]
|
||||
then
|
||||
prepair_wine
|
||||
|
||||
# Inno Setup. Файлы CADCAMCAPPAdem90st-N.bin должны лежать рядом с exe.
|
||||
# Установщик сам вызывает SwitchLang.exe (xcopy русских ресурсов) и regsvr32.
|
||||
wine_run_install "$2" # /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
|
||||
|
||||
ADEM_PATH="$DRIVE_C/Program Files (x86)/Adem Group/Adem90"
|
||||
create_desktop "$PROG_NAME" "$ADEM_PATH/2-d/admsrv90r.exe" "$PROG_ICON"
|
||||
else
|
||||
fatal "Не найден файл установки CADCAMCAPPAdem90st.exe. Перезапустите по примеру:
|
||||
winehelper install $1 \"/путь/до/CADCAMCAPPAdem90st.exe\""
|
||||
fi
|
||||
@@ -1,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
@@ -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"
|
||||
|
||||
+9
-1
@@ -1,4 +1,6 @@
|
||||
##### 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
|
||||
@@ -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
|
||||
+58
-9
@@ -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"
|
||||
@@ -2528,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"
|
||||
|
||||
@@ -3091,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
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Модуль вывода предупреждения о проверке установки КриптоПро в системе.
|
||||
|
||||
Выводит информационное окно со ссылками на официальный сайт КриптоПро
|
||||
и инструкцию по установке КриптоПро в систему.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from winehelperqt.qt_compat import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
QTextBrowser,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
|
||||
class CryptoProWarningDialog(QDialog):
|
||||
"""Диалог предупреждения о проверке установки КриптоПро."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("WineHelper — Предупреждение!")
|
||||
self.setMinimumSize(600, 400)
|
||||
self.setModal(True)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(12)
|
||||
|
||||
self.info_browser = QTextBrowser()
|
||||
self.info_browser.setOpenExternalLinks(True)
|
||||
self.info_browser.setStyleSheet("""
|
||||
QTextBrowser {
|
||||
background-color: #1e1e1e;
|
||||
color: #e4e4e4;
|
||||
border: none;
|
||||
}
|
||||
""")
|
||||
self.info_browser.setHtml(self._build_html_content())
|
||||
layout.addWidget(self.info_browser)
|
||||
|
||||
def _build_html_content(self) -> str:
|
||||
"""Формирует HTML-содержимое окна."""
|
||||
body_style = (
|
||||
"font-family: 'Segoe UI', 'Noto Sans', sans-serif; font-size: 14px; color: #e4e4e4; line-height: 1.55;"
|
||||
)
|
||||
warning_style = "background-color: #3d351f; border-left: 4px solid #ffcc00; padding: 12px 14px; margin: 0;"
|
||||
warning_text_style = "color: #ffd700; font-weight: bold; text-align: center; margin: 0 0 6px 0;"
|
||||
card_style = "padding: 14px; margin: 0;"
|
||||
title_style = "font-size: 15px; font-weight: bold; color: #ffffff; margin: 0 0 10px 0;"
|
||||
paragraph_style = "margin: 0 0 10px 0;"
|
||||
link_style = "color: #7ec7ff;"
|
||||
|
||||
return f"""
|
||||
<html>
|
||||
<body style="{body_style}">
|
||||
<p style="text-align: center; font-size: 20px; font-weight: bold; color: #ffd700; margin: 0 0 8px 0;">
|
||||
Предупреждение!
|
||||
</p>
|
||||
<div style="{warning_style}">
|
||||
<p style="{warning_text_style} margin-bottom: 0;">
|
||||
⚠️ Проверьте правильность установки КриптоПро в системе!
|
||||
</p>
|
||||
</div>
|
||||
<div style="line-height: 18px; font-size: 1px;"> </div>
|
||||
|
||||
<div style="{card_style}">
|
||||
<p style="{title_style}">Официальный сайт КриптоПро</p>
|
||||
<p style="{paragraph_style}">
|
||||
<a style="{link_style}" href="https://cryptopro.ru/products/csp">
|
||||
https://cryptopro.ru/products/csp
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p style="{title_style} margin-top: 14px;">Инструкция установки КриптоПро в систему</p>
|
||||
<p style="{paragraph_style} margin-bottom: 0;">
|
||||
<a style="{link_style}" href="https://www.altlinux.org/CryptoPro">
|
||||
https://www.altlinux.org/CryptoPro
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Точка входа для автономного запуска модуля."""
|
||||
app = QApplication(sys.argv)
|
||||
dialog = CryptoProWarningDialog()
|
||||
dialog.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Executable → Regular
+5
-4
@@ -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;"> </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__":
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Слой совместимости PyQt6/PyQt5.
|
||||
|
||||
Весь GUI-код проекта импортирует Qt только отсюда:
|
||||
|
||||
from winehelperqt.qt_compat import Qt, QWidget, QT6
|
||||
|
||||
Выбор binding'а (приоритет сверху вниз):
|
||||
|
||||
1. Переменная окружения WH_QT_BINDING=pyqt5|pyqt6 — для тестирования.
|
||||
2. Настройка «Qt binding» во вкладке «Настройки» GUI
|
||||
(ключ qt_binding в ~/.config/winehelper/logging.conf): auto|pyqt6|pyqt5.
|
||||
3. auto — PyQt6, если он установлен и рабочий (p11, Sisyphus),
|
||||
иначе PyQt5 (p10, где PyQt6 отсутствует в репозитории).
|
||||
|
||||
В strict-режимах (pyqt5/pyqt6) fallback отключён: если выбранный binding
|
||||
недоступен, при импорте этого модуля поднимается ImportError с понятным
|
||||
сообщением (его перехватывает winehelper_gui.py и завершается с ошибкой).
|
||||
|
||||
Внешние прослойки (QtPy) не используются: в p10 лежит слишком старая версия.
|
||||
|
||||
Код проекта пишется в общем для обоих binding'ов стиле: enum'ы только
|
||||
в scoped-форме (Qt.AlignmentFlag.AlignCenter), exec() вместо exec_(),
|
||||
сравнения enum с int — через Qt.CheckState(state) == ...
|
||||
"""
|
||||
|
||||
import configparser
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
VALID_BINDINGS = ("auto", "pyqt6", "pyqt5")
|
||||
|
||||
# Тот же файл, что использует GlobalSettingsService; читаем напрямую,
|
||||
# т.к. binding выбирается при импорте, до инициализации сервисов GUI.
|
||||
_CONFIG_PATH = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "winehelper" / "logging.conf"
|
||||
|
||||
|
||||
def _read_binding_choice() -> str:
|
||||
"""Читает выбранный binding: WH_QT_BINDING > конфиг GUI > auto."""
|
||||
forced = os.environ.get("WH_QT_BINDING", "").lower().strip()
|
||||
if forced in ("pyqt5", "pyqt6"):
|
||||
return forced
|
||||
try:
|
||||
config = configparser.ConfigParser()
|
||||
config.read(_CONFIG_PATH, encoding="utf-8")
|
||||
saved = config["gui"].get("qt_binding", "auto").lower().strip()
|
||||
if saved in VALID_BINDINGS:
|
||||
return saved
|
||||
except (configparser.Error, OSError, KeyError):
|
||||
pass
|
||||
return "auto"
|
||||
|
||||
|
||||
BINDING = _read_binding_choice()
|
||||
|
||||
QT6 = False
|
||||
_loaded = False
|
||||
_import_errors = []
|
||||
|
||||
if BINDING in ("auto", "pyqt6"):
|
||||
try:
|
||||
from PyQt6 import sip
|
||||
from PyQt6.QtCore import (
|
||||
QEasingCurve,
|
||||
QObject,
|
||||
QProcess,
|
||||
QProcessEnvironment,
|
||||
QPropertyAnimation,
|
||||
QSettings,
|
||||
QSize,
|
||||
Qt,
|
||||
QTimer,
|
||||
pyqtSignal,
|
||||
)
|
||||
from PyQt6.QtGui import (
|
||||
QColor,
|
||||
QCursor,
|
||||
QFont,
|
||||
QFontDatabase,
|
||||
QIcon,
|
||||
QPainter,
|
||||
QPalette,
|
||||
QPixmap,
|
||||
QTextCharFormat,
|
||||
QTextCursor,
|
||||
)
|
||||
from PyQt6.QtNetwork import QLocalServer, QLocalSocket
|
||||
from PyQt6.QtWidgets import (
|
||||
QAbstractButton,
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QStackedWidget,
|
||||
QSystemTrayIcon,
|
||||
QTabBar,
|
||||
QTabWidget,
|
||||
QTextBrowser,
|
||||
QTextEdit,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
QT6 = True
|
||||
_loaded = True
|
||||
except ImportError as e:
|
||||
_import_errors.append(f"PyQt6: {e}")
|
||||
|
||||
if not _loaded and BINDING in ("auto", "pyqt5"):
|
||||
try:
|
||||
from PyQt5 import sip
|
||||
from PyQt5.QtCore import (
|
||||
QEasingCurve,
|
||||
QObject,
|
||||
QProcess,
|
||||
QProcessEnvironment,
|
||||
QPropertyAnimation,
|
||||
QSettings,
|
||||
QSize,
|
||||
Qt,
|
||||
QTimer,
|
||||
pyqtSignal,
|
||||
)
|
||||
from PyQt5.QtGui import (
|
||||
QColor,
|
||||
QCursor,
|
||||
QFont,
|
||||
QFontDatabase,
|
||||
QIcon,
|
||||
QPainter,
|
||||
QPalette,
|
||||
QPixmap,
|
||||
QTextCharFormat,
|
||||
QTextCursor,
|
||||
)
|
||||
from PyQt5.QtNetwork import QLocalServer, QLocalSocket
|
||||
from PyQt5.QtWidgets import (
|
||||
QAbstractButton,
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QStackedWidget,
|
||||
QSystemTrayIcon,
|
||||
QTabBar,
|
||||
QTabWidget,
|
||||
QTextBrowser,
|
||||
QTextEdit,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
_loaded = True
|
||||
except ImportError as e:
|
||||
_import_errors.append(f"PyQt5: {e}")
|
||||
|
||||
if not _loaded:
|
||||
details = "; ".join(str(e) for e in _import_errors)
|
||||
if BINDING == "auto":
|
||||
raise ImportError(
|
||||
"Не найден ни PyQt6, ни PyQt5. Установите python3-module-PyQt6 "
|
||||
f"(p11, Sisyphus) или python3-module-PyQt5 (p10). Детали: {details}"
|
||||
)
|
||||
raise ImportError(f"В настройках WineHelper выбран {BINDING}, но он недоступен. Детали: {details}")
|
||||
|
||||
CURRENT_BINDING = "pyqt6" if QT6 else "pyqt5"
|
||||
|
||||
|
||||
def available_bindings() -> list:
|
||||
"""Возвращает список установленных binding'ов ('pyqt5', 'pyqt6') без их загрузки."""
|
||||
result = []
|
||||
if importlib.util.find_spec("PyQt5") is not None:
|
||||
result.append("pyqt5")
|
||||
if importlib.util.find_spec("PyQt6") is not None and importlib.util.find_spec("PyQt6.sip") is not None:
|
||||
result.append("pyqt6")
|
||||
return result
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BINDING",
|
||||
"CURRENT_BINDING",
|
||||
"QAbstractButton",
|
||||
"QApplication",
|
||||
"QCheckBox",
|
||||
"QColor",
|
||||
"QComboBox",
|
||||
"QCursor",
|
||||
"QDialog",
|
||||
"QDialogButtonBox",
|
||||
"QEasingCurve",
|
||||
"QFileDialog",
|
||||
"QFont",
|
||||
"QFontDatabase",
|
||||
"QFormLayout",
|
||||
"QFrame",
|
||||
"QGridLayout",
|
||||
"QGroupBox",
|
||||
"QHBoxLayout",
|
||||
"QIcon",
|
||||
"QInputDialog",
|
||||
"QLabel",
|
||||
"QLineEdit",
|
||||
"QListWidget",
|
||||
"QListWidgetItem",
|
||||
"QLocalServer",
|
||||
"QLocalSocket",
|
||||
"QMainWindow",
|
||||
"QMenu",
|
||||
"QMessageBox",
|
||||
"QObject",
|
||||
"QPainter",
|
||||
"QPalette",
|
||||
"QPixmap",
|
||||
"QProcess",
|
||||
"QProcessEnvironment",
|
||||
"QProgressBar",
|
||||
"QPropertyAnimation",
|
||||
"QPushButton",
|
||||
"QRadioButton",
|
||||
"QScrollArea",
|
||||
"QSettings",
|
||||
"QSize",
|
||||
"QSizePolicy",
|
||||
"QStackedWidget",
|
||||
"QSystemTrayIcon",
|
||||
"QT6",
|
||||
"QTabBar",
|
||||
"QTabWidget",
|
||||
"QTextBrowser",
|
||||
"QTextCharFormat",
|
||||
"QTextCursor",
|
||||
"QTextEdit",
|
||||
"QTimer",
|
||||
"QToolButton",
|
||||
"QVBoxLayout",
|
||||
"QWidget",
|
||||
"Qt",
|
||||
"VALID_BINDINGS",
|
||||
"available_bindings",
|
||||
"pyqtSignal",
|
||||
"sip",
|
||||
]
|
||||
@@ -1,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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
"""
|
||||
Создаёт ключ кэша для скрипта на основе пути и времени модификации.
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -1,9 +1,3 @@
|
||||
"""
|
||||
Виджеты для winehelper GUI.
|
||||
"""
|
||||
|
||||
from winehelperqt.widgets.icon_manager import IconManager
|
||||
|
||||
__all__ = [
|
||||
"IconManager",
|
||||
]
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Вкладка «Справка» главного окна WineHelper.
|
||||
|
||||
Содержит подвкладки: Общее, Авторы, Лицензия, История изменений.
|
||||
"""
|
||||
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.qt_compat import QTabWidget, QTextBrowser, QTextEdit, QVBoxLayout, QWidget
|
||||
from winehelperqt.utils.environment import EnvironmentConfig
|
||||
from winehelperqt.utils.font_utils import fixed_font
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class HelpTab(QWidget):
|
||||
"""Вкладка «Справка» с подвкладками."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
help_layout = QVBoxLayout(self)
|
||||
help_layout.setContentsMargins(5, 5, 5, 5)
|
||||
|
||||
help_subtabs = QTabWidget()
|
||||
help_layout.addWidget(help_subtabs)
|
||||
|
||||
help_subtabs.addTab(self._create_general_subtab(), "Общее")
|
||||
help_subtabs.addTab(self._create_authors_subtab(), "Авторы")
|
||||
help_subtabs.addTab(self._create_license_subtab(), "Лицензия")
|
||||
help_subtabs.addTab(self._create_changelog_subtab(), "История изменений")
|
||||
|
||||
def _create_general_subtab(self):
|
||||
"""Создает подвкладку «Общее» с информацией из файла GENERAL."""
|
||||
general_tab = QWidget()
|
||||
general_layout = QVBoxLayout(general_tab)
|
||||
general_text = QTextBrowser()
|
||||
general_text.setOpenExternalLinks(True)
|
||||
|
||||
try:
|
||||
if not EnvironmentConfig.GENERAL or not os.path.exists(EnvironmentConfig.GENERAL):
|
||||
raise FileNotFoundError
|
||||
|
||||
with open(EnvironmentConfig.GENERAL, "r", encoding="utf-8") as f:
|
||||
general_content = f.read()
|
||||
|
||||
html_content = ""
|
||||
url_re = re.compile(r"(https?://[^\s]+)")
|
||||
|
||||
for line in general_content.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
html_content += "<br>"
|
||||
continue
|
||||
|
||||
line = html.escape(line)
|
||||
line = url_re.sub(r'<a href="\1">\1</a>', line)
|
||||
|
||||
if line.startswith("# "):
|
||||
html_content += f"<h2>{line[2:]}</h2>"
|
||||
elif line.startswith("Для "):
|
||||
html_content += f'<p style="margin-left: 10px;">• {line}</p>'
|
||||
else:
|
||||
html_content += f"<p>{line}</p>"
|
||||
|
||||
general_text.setHtml(html_content)
|
||||
except (FileNotFoundError, TypeError) as e:
|
||||
logger.warning("Не удалось загрузить общую информацию из %s: %s", EnvironmentConfig.GENERAL, e)
|
||||
general_text.setHtml(
|
||||
"<h2>Ошибка</h2>"
|
||||
"<p>Не удалось загрузить файл с общей информацией по пути:<br>"
|
||||
f"{EnvironmentConfig.GENERAL}</p>"
|
||||
)
|
||||
|
||||
general_layout.addWidget(general_text)
|
||||
return general_tab
|
||||
|
||||
def _create_authors_subtab(self):
|
||||
"""Создает подвкладку «Авторы»."""
|
||||
authors_tab = QWidget()
|
||||
authors_layout = QVBoxLayout(authors_tab)
|
||||
authors_text = QTextEdit()
|
||||
authors_text.setReadOnly(True)
|
||||
authors_text.setHtml(
|
||||
'<div style="text-align: center; font-size: 10pt;">'
|
||||
'<p><span style="font-size: 11pt;"><b>Разработчики</b></span><br>'
|
||||
"Михаил Тергоев (fidel)<br>"
|
||||
"Сергей Пальчех (minergenon)</p>"
|
||||
'<p><span style="font-size: 11pt;"><b>Помощники</b></span><br>'
|
||||
"Иван Мажукин (vanomj)</p>"
|
||||
'<p><span style="font-size: 11pt;"><b>Идея и поддержка</b></span><br>'
|
||||
"ООО "Базальт СПО"<br>"
|
||||
"ALT Linux Team</p>"
|
||||
"<br>"
|
||||
"<p>Отдельная благодарность всем, кто вносит свой вклад в "
|
||||
"развитие проекта,<br>"
|
||||
"тестирует и сообщает об ошибках!</p>"
|
||||
"</div>"
|
||||
)
|
||||
authors_layout.addWidget(authors_text)
|
||||
return authors_tab
|
||||
|
||||
def _create_license_subtab(self):
|
||||
"""Создает подвкладку «Лицензия» с текстом лицензии и списком сторонних компонентов."""
|
||||
license_tab = QWidget()
|
||||
license_layout = QVBoxLayout(license_tab)
|
||||
license_text = QTextBrowser()
|
||||
license_text.setOpenExternalLinks(True)
|
||||
|
||||
try:
|
||||
if not EnvironmentConfig.LICENSE_FILE:
|
||||
raise FileNotFoundError
|
||||
if not os.path.exists(EnvironmentConfig.LICENSE_FILE):
|
||||
raise FileNotFoundError
|
||||
|
||||
with open(EnvironmentConfig.LICENSE_FILE, "r", encoding="utf-8") as f:
|
||||
license_content = f.read()
|
||||
|
||||
escaped_license_content = html.escape(license_content)
|
||||
|
||||
license_html = (
|
||||
"<h2>Лицензия</h2>"
|
||||
"<pre style=\"font-family: 'DejaVu Sans Mono', monospace; "
|
||||
"font-size: 9pt; padding: 10px; border: 1px solid #a0a0a0; "
|
||||
'border-radius: 5px;">'
|
||||
f"{escaped_license_content}</pre>"
|
||||
"<hr>"
|
||||
"<h2>Сторонние компоненты</h2>"
|
||||
"<p>Некоторые компоненты, используемые или устанавливаемые "
|
||||
"данным ПО, могут иметь собственные лицензии. Пользователь "
|
||||
"несет полную ответственность за соблюдение этих лицензионных "
|
||||
"соглашений.</p>"
|
||||
"<p>Ниже приведен список основных сторонних компонентов и "
|
||||
"ссылки на их исходный код:</p>"
|
||||
)
|
||||
|
||||
# Читаем и парсим файл THIRD-PARTY
|
||||
third_party_html = ""
|
||||
third_party_file_path = EnvironmentConfig.THIRD_PARTY_FILE
|
||||
if third_party_file_path and os.path.exists(third_party_file_path):
|
||||
with open(third_party_file_path, "r", encoding="utf-8") as f_tp:
|
||||
third_party_content = f_tp.read()
|
||||
|
||||
# Преобразуем контент в HTML
|
||||
third_party_html += "<blockquote>"
|
||||
for line in third_party_content.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
third_party_html += "<br>"
|
||||
continue
|
||||
escaped_line = html.escape(line)
|
||||
if line.startswith("http"):
|
||||
third_party_html += (
|
||||
f" "
|
||||
f'<a href="{escaped_line}" style="font-size: 10pt;">'
|
||||
f"{escaped_line}</a><br>"
|
||||
)
|
||||
else:
|
||||
third_party_html += f"<b>{escaped_line}</b><br>"
|
||||
third_party_html += "</blockquote>"
|
||||
|
||||
license_text.setHtml(license_html + third_party_html)
|
||||
except (FileNotFoundError, TypeError) as e:
|
||||
logger.warning("Не удалось загрузить лицензию из %s: %s", EnvironmentConfig.LICENSE_FILE, e)
|
||||
license_text.setHtml(
|
||||
"<h2>Лицензия</h2>"
|
||||
"<p>Не удалось загрузить файл лицензии по пути:<br>"
|
||||
f"{EnvironmentConfig.LICENSE_FILE}</p>"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения файла лицензии %s: %s", EnvironmentConfig.LICENSE_FILE, e)
|
||||
license_text.setHtml(f"<h2>Лицензия</h2><p>Произошла ошибка при чтении файла лицензии:<br>{str(e)}</p>")
|
||||
|
||||
license_layout.addWidget(license_text)
|
||||
return license_tab
|
||||
|
||||
def _create_changelog_subtab(self):
|
||||
"""Создает подвкладку «История изменений» с содержимым файла CHANGELOG."""
|
||||
changelog_tab = QWidget()
|
||||
changelog_layout = QVBoxLayout(changelog_tab)
|
||||
changelog_text = QTextEdit()
|
||||
changelog_text.setReadOnly(True)
|
||||
changelog_text.setFont(fixed_font(9))
|
||||
|
||||
try:
|
||||
if not EnvironmentConfig.CHANGELOG_FILE or not os.path.exists(EnvironmentConfig.CHANGELOG_FILE):
|
||||
raise FileNotFoundError
|
||||
with open(EnvironmentConfig.CHANGELOG_FILE, "r", encoding="utf-8") as f:
|
||||
changelog_content = f.read()
|
||||
changelog_text.setText(changelog_content)
|
||||
except (FileNotFoundError, TypeError) as e:
|
||||
logger.warning("Не удалось загрузить историю изменений из %s: %s", EnvironmentConfig.CHANGELOG_FILE, e)
|
||||
changelog_text.setText(f"Файл истории изменений не найден по пути:\n{EnvironmentConfig.CHANGELOG_FILE}")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения истории изменений %s: %s", EnvironmentConfig.CHANGELOG_FILE, e)
|
||||
changelog_text.setText(f"Не удалось прочитать файл истории изменений:\n{str(e)}")
|
||||
|
||||
changelog_layout.addWidget(changelog_text)
|
||||
return changelog_tab
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Контроллер правой информационной панели главного окна WineHelper.
|
||||
|
||||
Единая точка координации панели: заголовок и описание принадлежат контроллеру,
|
||||
вкладки добавляют свои секции кнопок через add_section() и управляют
|
||||
видимостью через явные методы вместо прямого доступа к чужим виджетам.
|
||||
"""
|
||||
|
||||
from winehelperqt.qt_compat import (
|
||||
QFrame,
|
||||
QLabel,
|
||||
QPixmap,
|
||||
QSizePolicy,
|
||||
Qt,
|
||||
QTextBrowser,
|
||||
QVBoxLayout,
|
||||
)
|
||||
from winehelperqt.utils.font_utils import app_font
|
||||
|
||||
|
||||
class InfoPanelController(QFrame):
|
||||
"""
|
||||
Правая информационная панель главного окна.
|
||||
|
||||
Содержит заголовок (script_title) и описание (script_description);
|
||||
секции кнопок вкладок добавляются через add_section() в порядке:
|
||||
заголовок, описание, путь к установочному файлу, действия установщика
|
||||
(добавляются главным окном), действия приложения, spacer, глобальные
|
||||
действия «Установленные» (добавляются InstalledTab).
|
||||
"""
|
||||
|
||||
# Индексы строк layout панели — используются в reset_stretch().
|
||||
# Зависят от порядка addWidget/add_section выше; при изменении порядка
|
||||
# добавления обновить и константы.
|
||||
INDEX_TITLE = 0
|
||||
INDEX_DESCRIPTION = 1
|
||||
INDEX_MANUAL_INSTALL_PATH = 2
|
||||
INDEX_INSTALL_ACTIONS = 3
|
||||
INDEX_INSTALLED_ACTIONS = 4
|
||||
INDEX_INSTALLED_SPACER = 5
|
||||
INDEX_INSTALLED_GLOBAL_ACTIONS = 6
|
||||
|
||||
def __init__(self, parent=None):
|
||||
"""
|
||||
Инициализирует панель: рамка, шрифт, заголовок и описание.
|
||||
|
||||
Args:
|
||||
parent: Родительский виджет (главное окно)
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
self.setMinimumWidth(400) # Размер инф. панели
|
||||
self.setFont(app_font(10)) # Размер шрифта в инф. панели
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Заголовок
|
||||
self.script_title = QLabel("Выберите программу")
|
||||
self.script_title.setFont(app_font(12, bold=True))
|
||||
self.script_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
# Заголовок не растягивается по вертикали, чтобы кнопка "Запустить" поднялась
|
||||
self.script_title.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
||||
# Изначально скрыт: появляется только при выборе программы
|
||||
self.script_title.setVisible(False)
|
||||
layout.addWidget(self.script_title)
|
||||
|
||||
# Описание
|
||||
self.script_description = QTextBrowser()
|
||||
self.script_description.setReadOnly(True)
|
||||
self.script_description.setOpenExternalLinks(True)
|
||||
layout.addWidget(self.script_description)
|
||||
|
||||
def add_section(self, widget):
|
||||
"""Добавляет секцию кнопок вкладки вниз панели."""
|
||||
self.layout().addWidget(widget)
|
||||
|
||||
def set_visible(self, visible):
|
||||
"""Показывает или скрывает панель целиком."""
|
||||
self.setVisible(visible)
|
||||
|
||||
def set_title(self, text):
|
||||
"""Устанавливает текст заголовка панели."""
|
||||
self.script_title.setText(text)
|
||||
|
||||
def set_title_icon(self, pixmap: QPixmap):
|
||||
"""Устанавливает иконку заголовка панели (пустой QPixmap — без иконки)."""
|
||||
self.script_title.setPixmap(pixmap)
|
||||
|
||||
def set_title_visible(self, visible):
|
||||
"""Показывает или скрывает заголовок панели."""
|
||||
self.script_title.setVisible(visible)
|
||||
|
||||
def set_description(self, html_content):
|
||||
"""Устанавливает HTML-содержимое описания панели."""
|
||||
self.script_description.setHtml(html_content)
|
||||
|
||||
def set_description_visible(self, visible):
|
||||
"""Показывает или скрывает описание панели."""
|
||||
self.script_description.setVisible(visible)
|
||||
|
||||
def reset_stretch(self):
|
||||
"""Сбрасывает растяжение к состоянию по умолчанию: растягивается описание, а не секции с кнопками."""
|
||||
self.layout().setStretch(self.INDEX_DESCRIPTION, 1)
|
||||
self.layout().setStretch(self.INDEX_INSTALLED_ACTIONS, 0)
|
||||
@@ -0,0 +1,425 @@
|
||||
"""
|
||||
Вкладки установки главного окна WineHelper.
|
||||
|
||||
InstallTab — вкладки «Автоматическая установка» и «Ручная установка»
|
||||
(сетка кнопок скриптов установки с поиском).
|
||||
ExternalInstallTab — вкладка «Сторонняя установка» (установка из собственного
|
||||
скрипта пользователя).
|
||||
"""
|
||||
|
||||
import html
|
||||
import os
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.qt_compat import (
|
||||
QCheckBox,
|
||||
QFileDialog,
|
||||
QFrame,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPixmap,
|
||||
QPushButton,
|
||||
Qt,
|
||||
QTimer,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from winehelperqt.utils.environment import EnvironmentConfig
|
||||
from winehelperqt.utils.grid_utils import GridUtils
|
||||
from winehelperqt.widgets.tab_names import TabNames
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class InstallTab(QWidget):
|
||||
"""Вкладка установки программ из скриптов (автоматической или ручной)."""
|
||||
|
||||
def __init__(self, gui, tab_type, script_folders, search_placeholder):
|
||||
"""
|
||||
Инициализирует вкладку установки.
|
||||
|
||||
Args:
|
||||
gui: Главное окно WineHelperGUI (доступ к общим сервисам и колбэкам)
|
||||
tab_type: Тип вкладки ('auto' или 'manual')
|
||||
script_folders: Список папок со скриптами ('autoinstall', 'manualinstall')
|
||||
search_placeholder: Подсказка для поля поиска
|
||||
"""
|
||||
super().__init__(gui)
|
||||
self.gui = gui
|
||||
self.tab_type = tab_type
|
||||
|
||||
# Локальные ссылки на общие сервисы главного окна
|
||||
self.script_manager = gui.script_manager
|
||||
self.icon_manager = gui.icon_manager
|
||||
|
||||
# Состояние вкладки
|
||||
self.scripts = [] # Имена скриптов вкладки
|
||||
self.buttons = [] # Кнопки скриптов в сетке
|
||||
self.test_buttons = [] # Кнопки тестовых скриптов (только для 'auto')
|
||||
self.test_checkbox = None # Чекбокс «Показать тестовые версии» (только для 'auto')
|
||||
|
||||
# Содержимое вкладки: поиск + сетка кнопок (общий помощник главного окна)
|
||||
content = gui.create_searchable_grid_tab(search_placeholder, self.filter_buttons, with_description_search=True)
|
||||
self.grid_layout = content.grid_layout
|
||||
self.search_edit = content.search_edit
|
||||
self.scroll_area = content.scroll_area
|
||||
self.search_desc_checkbox = content.description_checkbox
|
||||
self.empty_label = content.empty_label
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(5)
|
||||
layout.addWidget(content.widget)
|
||||
|
||||
self._populate_scripts(script_folders)
|
||||
|
||||
# Чекбокс для тестовых версий — только на вкладке автоматической установки
|
||||
if self.tab_type == "auto":
|
||||
self.test_checkbox = QCheckBox("Показать тестовые версии")
|
||||
self.test_checkbox.setToolTip("Показать/скрыть экспериментальные скрипты установки")
|
||||
self.test_checkbox.stateChanged.connect(self.update_auto_install_list)
|
||||
layout.addWidget(self.test_checkbox)
|
||||
|
||||
# Начальное состояние метки пустого состояния (на случай пустого списка скриптов)
|
||||
self.filter_buttons()
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Возобновляет анимации иконок при показе вкладки."""
|
||||
super().showEvent(event)
|
||||
self._set_animations_running(True)
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""Останавливает таймеры анимаций кнопок, пока вкладка скрыта."""
|
||||
super().hideEvent(event)
|
||||
self._set_animations_running(False)
|
||||
|
||||
def _set_animations_running(self, running):
|
||||
"""Запускает или приостанавливает таймеры смены иконок всех кнопок вкладки."""
|
||||
for btn in self.buttons:
|
||||
if running:
|
||||
self.icon_manager.resume_button_animation(btn)
|
||||
else:
|
||||
self.icon_manager.pause_button_animation(btn)
|
||||
|
||||
def _populate_scripts(self, script_folders):
|
||||
"""Читает списки скриптов из папок и заполняет сетку кнопками."""
|
||||
for folder in script_folders:
|
||||
script_path = os.path.join(EnvironmentConfig.DATA_PATH, folder)
|
||||
if os.path.isdir(script_path):
|
||||
try:
|
||||
folder_scripts = sorted(os.listdir(script_path))
|
||||
self.scripts.extend(folder_scripts)
|
||||
self._populate_install_grid(folder_scripts, folder, self.buttons)
|
||||
except OSError as e:
|
||||
logger.error("Не удалось прочитать директорию %s: %s", script_path, e)
|
||||
|
||||
def _populate_install_grid(self, scripts_list, script_folder, button_list, start_index=0):
|
||||
"""
|
||||
Заполняет сетку вкладки кнопками установщиков.
|
||||
Кнопки создаются только для скриптов, в которых найдена переменная PROG_NAME.
|
||||
|
||||
:param scripts_list: Список имен скриптов.
|
||||
:param script_folder: Имя папки со скриптами ('autoinstall', 'manualinstall' или 'testinstall').
|
||||
:param button_list: Список для хранения созданных кнопок.
|
||||
:param start_index: Начальный индекс для расчета позиции в сетке.
|
||||
"""
|
||||
for script in scripts_list:
|
||||
script_path = os.path.join(EnvironmentConfig.DATA_PATH, script_folder, script)
|
||||
script_info = self.script_manager.get_script_info(script_path)
|
||||
|
||||
# Создаем кнопку, только если для скрипта указано имя программы
|
||||
if not script_info:
|
||||
continue
|
||||
|
||||
# Получаем пути к иконкам
|
||||
icon_paths = [
|
||||
os.path.join(EnvironmentConfig.DATA_PATH, "image", f"{name}.png") for name in script_info.icons
|
||||
]
|
||||
|
||||
# Выбираем стиль в зависимости от папки
|
||||
if script_folder == "testinstall":
|
||||
style_sheet = self.gui.EXPERIMENTAL_APP_BUTTON_STYLE
|
||||
frame_style = self.gui.EXPERIMENTAL_FRAME_STYLE
|
||||
else:
|
||||
style_sheet = self.gui.APP_BUTTON_STYLE
|
||||
frame_style = self.gui.SELECTION_FRAME_DEFAULT
|
||||
|
||||
frame, btn = self.icon_manager.create_button_with_frame(
|
||||
script_info.name, icon_paths, style_sheet, frame_style
|
||||
)
|
||||
|
||||
btn.clicked.connect(lambda checked, s=script, b=btn: self.show_script_info(s, b))
|
||||
# Сохраняем расширенный текст для поиска (имя + описание)
|
||||
btn.setProperty("search_text", f"{script_info.name} {script_info.description}")
|
||||
row, column = divmod(start_index + len(button_list), 2)
|
||||
self.grid_layout.addWidget(frame, row, column)
|
||||
button_list.append(btn)
|
||||
|
||||
def filter_buttons(self):
|
||||
"""Фильтрует кнопки вкладки по тексту поиска."""
|
||||
# При включённом чекбоксе ищем по расширенному тексту (имя + описание)
|
||||
if self.search_desc_checkbox is not None and self.search_desc_checkbox.isChecked():
|
||||
text_getter = lambda btn: btn.property("search_text") or btn.text() # noqa: E731
|
||||
else:
|
||||
text_getter = None
|
||||
visible = self.gui.filter_buttons_in_grid(self.search_edit.text(), self.buttons, self.grid_layout, text_getter)
|
||||
GridUtils.update_empty_state(self.empty_label, len(visible), self.search_edit.text(), "Список программ пуст")
|
||||
|
||||
def show_script_info(self, script_name, button_widget):
|
||||
"""Показывает информацию о выбранном скрипте"""
|
||||
gui = self.gui
|
||||
gui.set_active_button(button_widget)
|
||||
gui.current_script = script_name
|
||||
|
||||
# Определяем виджеты и действия в зависимости от типа вкладки
|
||||
if self.tab_type == "auto":
|
||||
script_path = os.path.join(EnvironmentConfig.DATA_PATH, "autoinstall", script_name)
|
||||
if not os.path.exists(script_path): # Проверяем в testinstall, если не нашли в autoinstall
|
||||
script_path = os.path.join(EnvironmentConfig.DATA_PATH, "testinstall", script_name)
|
||||
gui.set_manual_install_path_visible(False)
|
||||
else:
|
||||
script_path = os.path.join(EnvironmentConfig.DATA_PATH, "manualinstall", script_name)
|
||||
gui.set_manual_install_path_visible(True)
|
||||
|
||||
# Полный путь к скрипту: установка использует его, не угадывая папку повторно
|
||||
gui.current_script_path = script_path
|
||||
|
||||
# Если в поиске был текст, очищаем его и перерисовываем список.
|
||||
# Это предотвращает "прыжок", если список не был отфильтрован.
|
||||
if self.search_edit.text():
|
||||
self.search_edit.blockSignals(True)
|
||||
self.search_edit.clear()
|
||||
self.search_edit.blockSignals(False)
|
||||
self.filter_buttons()
|
||||
|
||||
frame = button_widget.parent()
|
||||
if isinstance(frame, QFrame):
|
||||
QTimer.singleShot(0, lambda: self.scroll_area.ensureWidgetVisible(frame))
|
||||
|
||||
# Обновляем информацию в правой панели
|
||||
script_info = self.script_manager.get_script_info(script_path)
|
||||
if not script_info:
|
||||
return
|
||||
|
||||
display_name = script_info.name if script_info.name else script_name
|
||||
gui.current_display_name = display_name
|
||||
|
||||
if script_info.icons:
|
||||
# Для заголовка используем первую иконку из списка
|
||||
icon_path = os.path.join(EnvironmentConfig.DATA_PATH, "image", f"{script_info.icons[0]}.png")
|
||||
if os.path.exists(icon_path):
|
||||
gui.info_panel.set_title_icon(QPixmap(icon_path).scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio))
|
||||
else:
|
||||
gui.info_panel.set_title_icon(QPixmap())
|
||||
else:
|
||||
gui.info_panel.set_title_icon(QPixmap())
|
||||
|
||||
gui.info_panel.set_title(display_name)
|
||||
gui.info_panel.set_title_visible(True)
|
||||
|
||||
# Экранируем данные из скрипта установки: они вставляются в HTML-виджет
|
||||
html_description = f"<p>{html.escape(script_info.description)}</p>"
|
||||
if script_info.url:
|
||||
escaped_url = html.escape(script_info.url)
|
||||
html_description += f'<p><b>Официальный сайт:</b> <a href="{escaped_url}">{escaped_url}</a></p>'
|
||||
|
||||
gui.info_panel.set_description(html_description)
|
||||
gui.info_panel.set_description_visible(True)
|
||||
gui.set_installer_action_visible(True)
|
||||
# Скрываем секцию действий вкладки «Установленные» (ею управляет сама вкладка)
|
||||
gui.installed_tab.show_default_panel(False)
|
||||
gui.install_button.setText(f"Установить «{display_name}»")
|
||||
|
||||
def update_auto_install_list(self):
|
||||
"""Обновляет список на вкладке 'Автоматическая установка' при изменении чекбокса."""
|
||||
is_checked = self.test_checkbox.isChecked()
|
||||
|
||||
# Если нужно показать тестовые версии и они еще не добавлены
|
||||
if is_checked and not self.test_buttons:
|
||||
self._add_test_scripts_to_ui()
|
||||
|
||||
# Если нужно скрыть тестовые версии и они были добавлены
|
||||
elif not is_checked and self.test_buttons:
|
||||
self._remove_test_scripts_from_ui()
|
||||
|
||||
# В любом случае применяем фильтр, чтобы скрыть/показать кнопки в соответствии с поиском
|
||||
if self.test_checkbox.isChecked():
|
||||
self.filter_buttons()
|
||||
|
||||
def _add_test_scripts_to_ui(self):
|
||||
"""Добавляет тестовые скрипты в UI."""
|
||||
test_script_folder = "testinstall"
|
||||
script_path = os.path.join(EnvironmentConfig.DATA_PATH, test_script_folder)
|
||||
if not os.path.isdir(script_path):
|
||||
return
|
||||
|
||||
try:
|
||||
folder_scripts = sorted(os.listdir(script_path))
|
||||
# Запоминаем, какие кнопки являются тестовыми
|
||||
new_test_buttons = []
|
||||
self._populate_install_grid(
|
||||
folder_scripts, test_script_folder, new_test_buttons, start_index=len(self.buttons)
|
||||
)
|
||||
self.test_buttons = new_test_buttons
|
||||
self.buttons.extend(new_test_buttons)
|
||||
self.scripts.extend(folder_scripts)
|
||||
|
||||
# Применяем фильтр и прокручиваем к первому новому элементу
|
||||
self.filter_buttons()
|
||||
if new_test_buttons:
|
||||
first_new_button = new_test_buttons[0]
|
||||
frame = first_new_button.parent()
|
||||
if isinstance(frame, QFrame):
|
||||
# Даем время на отрисовку перед прокруткой
|
||||
QTimer.singleShot(100, lambda: self.scroll_area.ensureWidgetVisible(frame, 50, 50))
|
||||
|
||||
except OSError as e:
|
||||
logger.error("Не удалось прочитать директорию %s: %s", script_path, e)
|
||||
|
||||
def _remove_test_scripts_from_ui(self):
|
||||
"""Удаляет тестовые скрипты из UI."""
|
||||
# Если текущая активная кнопка находится среди удаляемых, сбрасываем ее
|
||||
if self.gui.selected_app_button in self.test_buttons:
|
||||
self.gui.reset_info_panel_to_default(TabNames.AUTO_INSTALL)
|
||||
self.gui.selected_app_button = None
|
||||
|
||||
# Останавливаем анимацию и удаляем виджеты тестовых кнопок
|
||||
for btn in self.test_buttons:
|
||||
self.icon_manager.stop_button_animation(btn)
|
||||
|
||||
# Удаляем кнопку из основного списка
|
||||
if btn in self.buttons:
|
||||
self.buttons.remove(btn)
|
||||
|
||||
# Удаляем фрейм кнопки из layout
|
||||
frame = btn.parent()
|
||||
if frame:
|
||||
frame.deleteLater()
|
||||
|
||||
# Очищаем список тестовых кнопок
|
||||
self.test_buttons.clear()
|
||||
# Обновляем список скриптов
|
||||
self.scripts = [
|
||||
s for s in self.scripts if not os.path.exists(os.path.join(EnvironmentConfig.DATA_PATH, "testinstall", s))
|
||||
]
|
||||
|
||||
|
||||
class ExternalInstallTab(QWidget):
|
||||
"""Вкладка «Сторонняя установка» — установка из собственного скрипта пользователя."""
|
||||
|
||||
def __init__(self, gui):
|
||||
"""
|
||||
Инициализирует вкладку сторонней установки.
|
||||
|
||||
Args:
|
||||
gui: Главное окно WineHelperGUI (доступ к общим сервисам и колбэкам)
|
||||
"""
|
||||
super().__init__(gui)
|
||||
self.gui = gui
|
||||
|
||||
# Локальные ссылки на общие сервисы главного окна
|
||||
self.script_manager = gui.script_manager
|
||||
self.install_runner = gui.install_runner
|
||||
self.winehelper_path = gui.winehelper_path
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(10, 10, 10, 10)
|
||||
layout.setSpacing(10)
|
||||
|
||||
group_box = QGroupBox("Установка из стороннего скрипта")
|
||||
group_layout = QVBoxLayout(group_box)
|
||||
|
||||
path_layout = QHBoxLayout()
|
||||
self.external_script_path_edit = QLineEdit()
|
||||
self.external_script_path_edit.setPlaceholderText("Укажите путь к скрипту установки...")
|
||||
path_layout.addWidget(self.external_script_path_edit)
|
||||
browse_button = QPushButton("Обзор…")
|
||||
browse_button.setToolTip("Выбрать файл скрипта установки.")
|
||||
browse_button.clicked.connect(self.browse_external_script)
|
||||
path_layout.addWidget(browse_button)
|
||||
group_layout.addLayout(path_layout)
|
||||
|
||||
file_layout = QHBoxLayout()
|
||||
self.external_install_file_edit = QLineEdit()
|
||||
self.external_install_file_edit.setPlaceholderText("Установочный файл (необязательно)...")
|
||||
self.external_install_file_edit.setToolTip(
|
||||
"Путь к дистрибутиву (.exe, .msi), если скрипт рассчитан на установку из локального файла."
|
||||
)
|
||||
file_layout.addWidget(self.external_install_file_edit)
|
||||
browse_file_button = QPushButton("Обзор…")
|
||||
browse_file_button.setToolTip("Выбрать установочный файл.")
|
||||
browse_file_button.clicked.connect(self.browse_external_install_file)
|
||||
file_layout.addWidget(browse_file_button)
|
||||
group_layout.addLayout(file_layout)
|
||||
|
||||
self.external_clear_pfx_checkbox = QCheckBox("Не использовать готовый префикс")
|
||||
self.external_clear_pfx_checkbox.setToolTip("Установка без готового префикса-шаблона (флаг --clear-pfx).")
|
||||
group_layout.addWidget(self.external_clear_pfx_checkbox)
|
||||
|
||||
install_button = QPushButton("Установить")
|
||||
install_button.setMinimumHeight(32)
|
||||
install_button.clicked.connect(self.install_external_script)
|
||||
group_layout.addWidget(install_button)
|
||||
|
||||
layout.addWidget(group_box)
|
||||
layout.addStretch(1)
|
||||
|
||||
def browse_external_script(self):
|
||||
"""Открывает диалог выбора стороннего скрипта установки."""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Выберите скрипт установки", os.path.expanduser("~"), "Все файлы (*)"
|
||||
)
|
||||
if file_path:
|
||||
self.external_script_path_edit.setText(file_path)
|
||||
|
||||
def browse_external_install_file(self):
|
||||
"""Открывает диалог выбора установочного файла для стороннего скрипта."""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Выберите установочный файл",
|
||||
os.path.expanduser("~"),
|
||||
"Установочные файлы (*.exe *.msi);;Все файлы (*)",
|
||||
)
|
||||
if file_path:
|
||||
self.external_install_file_edit.setText(file_path)
|
||||
|
||||
def install_external_script(self):
|
||||
"""Запускает установку программы из стороннего скрипта."""
|
||||
script_path = self.external_script_path_edit.text().strip()
|
||||
if not script_path:
|
||||
QMessageBox.warning(self, "Ошибка", "Укажите путь к скрипту установки")
|
||||
return
|
||||
if not os.path.isfile(script_path):
|
||||
QMessageBox.warning(self, "Ошибка", f"Скрипт установки не найден:\n{script_path}")
|
||||
return
|
||||
|
||||
install_file = self.external_install_file_edit.text().strip()
|
||||
if install_file and not os.path.isfile(install_file):
|
||||
QMessageBox.warning(self, "Ошибка", f"Установочный файл не найден:\n{install_file}")
|
||||
return
|
||||
|
||||
if not self.gui.ensure_license_accepted():
|
||||
return # Пользователь отклонил лицензию
|
||||
|
||||
# Имя для заголовка диалога: из скрипта, иначе имя файла
|
||||
script_info = self.script_manager.get_script_info(script_path)
|
||||
if script_info and script_info.name:
|
||||
title_name = script_info.name
|
||||
else:
|
||||
title_name = os.path.basename(script_path)
|
||||
|
||||
self.gui.prefixes_before_install = self.gui.prefix_tab.get_current_prefixes()
|
||||
self.install_runner.create_dialog(title_name)
|
||||
clear_pfx = self.external_clear_pfx_checkbox.isChecked()
|
||||
QTimer.singleShot(
|
||||
100,
|
||||
lambda: self.install_runner.start(
|
||||
self.winehelper_path,
|
||||
script_path,
|
||||
install_file=install_file or None,
|
||||
external=True,
|
||||
clear_pfx=clear_pfx,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,813 @@
|
||||
"""
|
||||
Вкладка «Установленные» главного окна WineHelper.
|
||||
|
||||
Содержит сетку установленных приложений, запуск/остановку приложений,
|
||||
создание логов, резервное копирование/восстановление и удаление префиксов,
|
||||
а также секцию действий с приложением в правой информационной панели.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from functools import partial
|
||||
|
||||
from winehelperqt.dialogs.create_log import CreateLogDialog
|
||||
from winehelperqt.dialogs.settings import AppAdditionalSettingsDialog
|
||||
from winehelperqt.dialogs.winedebug_base import DEFAULT_WINEDEBUG
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.qt_compat import (
|
||||
QDialog,
|
||||
QFileDialog,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPixmap,
|
||||
QProcess,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QTimer,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from winehelperqt.services.desktop_file_manager import DesktopFileManager
|
||||
from winehelperqt.utils.desktop_entry import DesktopEntry
|
||||
from winehelperqt.utils.dialog_utils import DialogUtils
|
||||
from winehelperqt.utils.environment import EnvironmentConfig
|
||||
from winehelperqt.utils.grid_utils import GridUtils
|
||||
from winehelperqt.utils.process_utils import ProcessUtils
|
||||
from winehelperqt.widgets.tab_names import TabNames
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class InstalledTab(QWidget):
|
||||
"""Вкладка «Установленные»."""
|
||||
|
||||
def __init__(self, gui):
|
||||
"""
|
||||
Инициализирует вкладку установленных приложений.
|
||||
|
||||
Args:
|
||||
gui: Главное окно WineHelperGUI (доступ к общим сервисам и колбэкам)
|
||||
"""
|
||||
super().__init__(gui)
|
||||
self.gui = gui
|
||||
|
||||
# Локальные ссылки на общие сервисы главного окна
|
||||
self.desktop_file_manager = gui.desktop_file_manager
|
||||
self.app_launcher = gui.app_launcher
|
||||
self.prefix_manager = gui.prefix_manager
|
||||
self.icon_manager = gui.icon_manager
|
||||
self.install_runner = gui.install_runner
|
||||
self.command_runner = gui.command_runner
|
||||
self.winehelper_path = gui.winehelper_path
|
||||
|
||||
# Состояние вкладки
|
||||
self.current_selected_app = None # Данные выбранного установленного приложения
|
||||
self.installed_app_buttons = [] # Список кнопок установленных приложений
|
||||
self._is_stopping = False # Флаг ожидания остановки приложения
|
||||
self._debug_launch_active = False # Флаг запуска с созданием лога
|
||||
self._installed_apps_signature = None # Кэш сигнатуры сетки установленных (задача кэша)
|
||||
|
||||
# Содержимое вкладки: поиск + сетка кнопок (общий помощник главного окна)
|
||||
content = gui.create_searchable_grid_tab(
|
||||
"Поиск установленной программы...", self.filter_installed_buttons, add_stretch=True
|
||||
)
|
||||
self.installed_scroll_layout = content.grid_layout
|
||||
self.installed_search_edit = content.search_edit
|
||||
self.installed_scroll_area = content.scroll_area
|
||||
self.empty_label = content.empty_label
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(content.widget)
|
||||
|
||||
# Секция действий с выбранным приложением в правой панели главного окна
|
||||
self._create_installed_actions_section()
|
||||
self.installed_action_widget.setVisible(False)
|
||||
self.installed_global_action_widget.setVisible(False)
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Возобновляет анимации иконок при показе вкладки."""
|
||||
super().showEvent(event)
|
||||
self._set_animations_running(True)
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""Останавливает таймеры анимаций кнопок, пока вкладка скрыта."""
|
||||
super().hideEvent(event)
|
||||
self._set_animations_running(False)
|
||||
|
||||
def _set_animations_running(self, running):
|
||||
"""Запускает или приостанавливает таймеры смены иконок всех кнопок вкладки."""
|
||||
for btn in self.installed_app_buttons:
|
||||
if running:
|
||||
self.icon_manager.resume_button_animation(btn)
|
||||
else:
|
||||
self.icon_manager.pause_button_animation(btn)
|
||||
|
||||
def _create_installed_actions_section(self):
|
||||
"""Создаёт секцию с кнопками действий для установленных приложений."""
|
||||
# Виджет для действий с установленным приложением
|
||||
self.installed_action_widget = QWidget()
|
||||
installed_action_layout = QVBoxLayout()
|
||||
installed_action_layout.setContentsMargins(0, 0, 0, 0)
|
||||
installed_action_layout.setSpacing(0)
|
||||
|
||||
# Верхний ряд кнопок
|
||||
top_buttons_layout = QHBoxLayout()
|
||||
top_buttons_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.run_button = QPushButton("Запустить")
|
||||
self.run_button.setStyleSheet(self.gui.RUN_BUTTON_STYLE)
|
||||
self.run_button.clicked.connect(self.toggle_run_stop_app)
|
||||
top_buttons_layout.addWidget(self.run_button)
|
||||
installed_action_layout.addLayout(top_buttons_layout)
|
||||
|
||||
self.log_status_label = QLabel("Идёт создание и сохранение лога…")
|
||||
self.log_status_label.setVisible(False)
|
||||
self.log_status_label.setStyleSheet("color: #ff9800; font-weight: bold;")
|
||||
installed_action_layout.addWidget(self.log_status_label)
|
||||
|
||||
self.log_progress_bar = QProgressBar()
|
||||
self.log_progress_bar.setVisible(False)
|
||||
self.log_progress_bar.setRange(0, 0)
|
||||
self.log_progress_bar.setFixedHeight(4)
|
||||
self.log_progress_bar.setStyleSheet(
|
||||
"QProgressBar {border: none; border-radius: 2px; "
|
||||
"background-color: #e0e0e0;}"
|
||||
"QProgressBar::chunk {border-radius: 2px; background-color: #ff9800;}"
|
||||
)
|
||||
installed_action_layout.addWidget(self.log_progress_bar)
|
||||
|
||||
self.installed_action_widget.setLayout(installed_action_layout)
|
||||
self.gui.info_panel.add_section(self.installed_action_widget)
|
||||
|
||||
# Фиксированный промежуток между кнопкой "Запустить" и остальными кнопками.
|
||||
# Компенсирует подъём кнопки "Запустить", чтобы остальные кнопки
|
||||
# остались примерно на прежнем месте.
|
||||
self.installed_buttons_spacer = QWidget()
|
||||
self.installed_buttons_spacer.setFixedHeight(120)
|
||||
self.installed_buttons_spacer.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
self.installed_buttons_spacer.setVisible(False)
|
||||
self.gui.info_panel.add_section(self.installed_buttons_spacer)
|
||||
|
||||
# Глобальные действия для установленных приложения
|
||||
self.installed_global_action_widget = QWidget()
|
||||
installed_global_layout = QVBoxLayout()
|
||||
installed_global_layout.setContentsMargins(0, 10, 0, 0)
|
||||
|
||||
self.app_additional_settings_button = QPushButton("Дополнительные настройки приложения")
|
||||
settings_icon = self.icon_manager.theme_icon("configure-shortcuts", fallbacks=["configure"])
|
||||
self.app_additional_settings_button.setIcon(settings_icon)
|
||||
self.app_additional_settings_button.clicked.connect(self.open_app_additional_settings)
|
||||
installed_global_layout.addWidget(self.app_additional_settings_button)
|
||||
|
||||
self.create_log_button = QPushButton("Создать лог запуска программы")
|
||||
self.create_log_button.setIcon(self.icon_manager.theme_icon("view-list-text"))
|
||||
self.create_log_button.clicked.connect(self.run_installed_app_with_debug)
|
||||
installed_global_layout.addWidget(self.create_log_button)
|
||||
|
||||
self.backup_button = QPushButton("Создать резервную копию префикса")
|
||||
self.backup_button.setIcon(self.icon_manager.theme_icon("document-save"))
|
||||
self.backup_button.clicked.connect(self.backup_prefix_for_app)
|
||||
installed_global_layout.addWidget(self.backup_button)
|
||||
|
||||
self.open_log_dir_button = QPushButton("Открыть папку с логом/резервной копией префикса")
|
||||
self.open_log_dir_button.setIcon(self.icon_manager.theme_icon("folder-open"))
|
||||
self.open_log_dir_button.clicked.connect(self.open_log_directory)
|
||||
self.open_log_dir_button.setVisible(False)
|
||||
installed_global_layout.addWidget(self.open_log_dir_button)
|
||||
|
||||
self.uninstall_button = QPushButton("Удалить префикс")
|
||||
self.uninstall_button.setIcon(self.icon_manager.theme_icon("user-trash"))
|
||||
self.uninstall_button.clicked.connect(self.uninstall_app)
|
||||
installed_global_layout.addWidget(self.uninstall_button)
|
||||
|
||||
self.restore_prefix_button_panel = QPushButton("Восстановить префикс из резервной копии")
|
||||
self.restore_prefix_button_panel.setIcon(self.icon_manager.theme_icon("document-revert"))
|
||||
self.restore_prefix_button_panel.clicked.connect(self.restore_prefix)
|
||||
installed_global_layout.addWidget(self.restore_prefix_button_panel)
|
||||
|
||||
self.installed_global_action_widget.setLayout(installed_global_layout)
|
||||
self.gui.info_panel.add_section(self.installed_global_action_widget)
|
||||
|
||||
def show_default_panel(self, show_global_actions=False):
|
||||
"""Сбрасывает секцию действий установленного приложения в состояние по умолчанию."""
|
||||
self.installed_action_widget.setVisible(False)
|
||||
self.installed_buttons_spacer.setVisible(False)
|
||||
self.installed_global_action_widget.setVisible(show_global_actions)
|
||||
self.app_additional_settings_button.setVisible(False)
|
||||
self.backup_button.setVisible(False)
|
||||
self.create_log_button.setVisible(False)
|
||||
self.open_log_dir_button.setVisible(False)
|
||||
self.uninstall_button.setVisible(False)
|
||||
self.current_selected_app = None
|
||||
|
||||
def show_app_panel(self):
|
||||
"""Показывает секцию действий для выбранного установленного приложения."""
|
||||
self.installed_action_widget.setVisible(True)
|
||||
self.installed_buttons_spacer.setVisible(True)
|
||||
self.installed_global_action_widget.setVisible(True)
|
||||
self.app_additional_settings_button.setVisible(True)
|
||||
self.backup_button.setVisible(True)
|
||||
self.create_log_button.setVisible(True)
|
||||
self.open_log_dir_button.setVisible(True)
|
||||
self.uninstall_button.setVisible(True)
|
||||
self.update_open_log_dir_button_state()
|
||||
|
||||
def update_installed_apps(self):
|
||||
"""Обновляет список установленных приложений в виде кнопок"""
|
||||
# Получаем информацию о приложениях из сервиса
|
||||
apps = self.desktop_file_manager.get_installed_apps_with_info()
|
||||
|
||||
# Кэш: сетка пересобирается только при изменении сигнатуры —
|
||||
# набора .desktop файлов с их mtime и фактом существования .exe.
|
||||
# Сценарии установки/удаления/восстановления/создания ярлыка меняют
|
||||
# набор файлов, их содержимое или .exe — сигнатура это ловит.
|
||||
# Зелёная рамка запущенных приложений и панель действий обновляются
|
||||
# отдельными методами и от пересбора сетки не зависят.
|
||||
try:
|
||||
signature = tuple(
|
||||
(app["path"], os.path.getmtime(app["path"]), DesktopFileManager.get_exe_path_from_exec(app["exec"]))
|
||||
for app in apps
|
||||
)
|
||||
except OSError:
|
||||
signature = None # Гонка с удалением файла — пересобираем
|
||||
if signature is not None and signature == self._installed_apps_signature:
|
||||
return
|
||||
self._installed_apps_signature = signature
|
||||
|
||||
# Если активная кнопка находится в списке удаляемых, сбрасываем ее
|
||||
if self.gui.selected_app_button in self.installed_app_buttons:
|
||||
self.gui.selected_app_button = None
|
||||
|
||||
# Останавливаем анимации удаляемых кнопок: QPropertyAnimation живёт
|
||||
# в IconManager и без остановки переживёт кнопку, обращаясь к удалённому виджету
|
||||
for btn in self.installed_app_buttons:
|
||||
self.icon_manager.stop_button_animation(btn)
|
||||
|
||||
# Полностью очищаем layout перед обновлением
|
||||
while self.installed_scroll_layout.count():
|
||||
item = self.installed_scroll_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget:
|
||||
widget.deleteLater()
|
||||
self.installed_app_buttons.clear()
|
||||
|
||||
for i, app_info in enumerate(apps):
|
||||
desktop_path = app_info["path"]
|
||||
display_name = app_info["display_name"]
|
||||
icon_path = app_info.get("icon")
|
||||
|
||||
frame, btn = self.icon_manager.create_button_with_frame(
|
||||
display_name,
|
||||
[icon_path] if icon_path else [],
|
||||
self.gui.INSTALLED_APP_BUTTON_STYLE,
|
||||
self.gui.SELECTION_FRAME_DEFAULT,
|
||||
)
|
||||
|
||||
# Клик по кнопке показывает информацию о приложении
|
||||
btn.clicked.connect(lambda _, p=desktop_path, b=btn: self.show_installed_app_info(p, b))
|
||||
|
||||
# Привязываем кнопку к приложению и помечаем запущенные зелёной рамкой
|
||||
btn.setProperty("desktop_path", desktop_path)
|
||||
if self.app_launcher.is_app_running(desktop_path):
|
||||
btn.setStyleSheet(self.gui.RUNNING_APP_BUTTON_STYLE)
|
||||
|
||||
row = i // 2
|
||||
column = i % 2
|
||||
self.installed_scroll_layout.addWidget(frame, row, column)
|
||||
self.installed_app_buttons.append(btn)
|
||||
|
||||
# Переприменяем фильтр (может быть активен поиск) и метку пустого состояния
|
||||
self.filter_installed_buttons()
|
||||
|
||||
def show_installed_app_info(self, desktop_path, button_widget):
|
||||
"""Показывает информацию об установленном приложении в правой панели."""
|
||||
self.gui.set_active_button(button_widget)
|
||||
# Если в поиске был текст, очищаем его и перерисовываем список.
|
||||
if self.installed_search_edit.text():
|
||||
self.installed_search_edit.blockSignals(True)
|
||||
self.installed_search_edit.clear()
|
||||
self.installed_search_edit.blockSignals(False)
|
||||
self.filter_installed_buttons()
|
||||
|
||||
# Прокручиваем к выбранному элементу
|
||||
frame = button_widget.parent()
|
||||
if isinstance(frame, QFrame):
|
||||
QTimer.singleShot(0, lambda: self.installed_scroll_area.ensureWidgetVisible(frame))
|
||||
|
||||
# Используем сервис для чтения .desktop файла
|
||||
app_info = self.desktop_file_manager.read_desktop_file(os.path.splitext(os.path.basename(desktop_path))[0])
|
||||
|
||||
self.current_selected_app = {
|
||||
"desktop_path": desktop_path,
|
||||
"name": app_info.get("Name", ""),
|
||||
"exec": app_info.get("Exec", ""),
|
||||
}
|
||||
|
||||
is_app_running = self.app_launcher.is_app_running(desktop_path)
|
||||
self._set_run_button_state(is_app_running)
|
||||
|
||||
# Показываем панель информации
|
||||
self.gui.info_panel.set_visible(True)
|
||||
|
||||
# Показываем информацию в правой панели
|
||||
self.gui.info_panel.set_title_icon(QPixmap())
|
||||
self.gui.info_panel.set_title(app_info.get("Name", ""))
|
||||
self.gui.info_panel.set_title_visible(True)
|
||||
self.gui.info_panel.set_description_visible(False)
|
||||
|
||||
# Управляем видимостью кнопок через фасадные методы вкладки и окна
|
||||
self.gui.set_installer_action_visible(False)
|
||||
self.gui.set_manual_install_path_visible(False)
|
||||
self.show_app_panel()
|
||||
|
||||
# Кнопка "Дополнительные настройки приложения" неактивна когда приложение запущено
|
||||
self.app_additional_settings_button.setEnabled(not is_app_running)
|
||||
|
||||
def update_open_log_dir_button_state(self):
|
||||
"""Проверяет наличие лог-файла или бэкапов и обновляет состояние кнопки."""
|
||||
log_dir_path = EnvironmentConfig.WH_BACKUP_LOG_DIR
|
||||
has_content = False
|
||||
if os.path.isdir(log_dir_path):
|
||||
# Кнопка активна, если директория не пуста.
|
||||
if os.listdir(log_dir_path):
|
||||
has_content = True
|
||||
self.open_log_dir_button.setEnabled(has_content)
|
||||
|
||||
def open_log_directory(self):
|
||||
"""Открывает директорию с лог-файлами."""
|
||||
log_dir_path = EnvironmentConfig.WH_BACKUP_LOG_DIR
|
||||
if os.path.isdir(log_dir_path):
|
||||
try:
|
||||
subprocess.Popen(["xdg-open", log_dir_path])
|
||||
logger.debug("Открыта директория с логами: %s", log_dir_path)
|
||||
except Exception as e:
|
||||
logger.error("Не удалось открыть директорию с логами %s: %s", log_dir_path, e)
|
||||
QMessageBox.warning(self, "Ошибка", f"Не удалось открыть директорию:\n{log_dir_path}\n\nОшибка: {e}")
|
||||
else:
|
||||
logger.warning("Директория с логами не найдена: %s", log_dir_path)
|
||||
QMessageBox.information(self, "Информация", f"Директория с логами не найдена:\n{log_dir_path}")
|
||||
|
||||
def get_prefix_name_for_selected_app(self):
|
||||
"""Извлекает имя префикса для выбранного приложения."""
|
||||
if not self.current_selected_app or "desktop_path" not in self.current_selected_app:
|
||||
return None
|
||||
return self.get_prefix_name_from_desktop_file(self.current_selected_app["desktop_path"])
|
||||
|
||||
def get_prefix_name_from_desktop_file(self, desktop_file):
|
||||
"""Извлекает имя префикса из Exec-строки указанного .desktop файла."""
|
||||
if not os.path.exists(desktop_file):
|
||||
return None
|
||||
|
||||
try:
|
||||
entry = DesktopEntry(desktop_file)
|
||||
entry.load()
|
||||
exec_line = entry.get("Desktop Entry", "Exec", "")
|
||||
if not exec_line:
|
||||
return None
|
||||
|
||||
match = re.search(r"prefixes/([^/]+)", exec_line)
|
||||
if match:
|
||||
prefix_path = match.group(1)
|
||||
return os.path.basename(os.path.normpath(prefix_path))
|
||||
except Exception as e:
|
||||
logger.error("Не удалось определить имя префикса из %s: %s", desktop_file, e)
|
||||
|
||||
return None
|
||||
|
||||
def backup_prefix_for_app(self):
|
||||
"""Создает резервную копию префикса для выбранного приложения."""
|
||||
prefix_name = self.get_prefix_name_for_selected_app()
|
||||
if not prefix_name:
|
||||
QMessageBox.warning(self, "Ошибка", "Не удалось определить префикс для выбранного приложения.")
|
||||
return
|
||||
|
||||
if not DialogUtils.confirm(
|
||||
self,
|
||||
"Создание резервной копии",
|
||||
f"Будет создана резервная копия префикса '{prefix_name}'.\n\n"
|
||||
"Файл будет сохранен в домашней директории в папке "
|
||||
"winehelper_backup_log/ в формате .whpack.\n\n"
|
||||
"Продолжить?",
|
||||
):
|
||||
return
|
||||
|
||||
def on_finished(context, exit_code, exit_status):
|
||||
self.command_runner.handle_finished(context, exit_code, exit_status)
|
||||
if exit_code == 0:
|
||||
self.update_open_log_dir_button_state()
|
||||
|
||||
context = self.command_runner.create_dialog(
|
||||
f"Резервное копирование: {prefix_name}",
|
||||
self.winehelper_path,
|
||||
["backup-prefix", prefix_name],
|
||||
finished_callback=on_finished,
|
||||
)
|
||||
context.dialog.exec()
|
||||
|
||||
def restore_prefix(self):
|
||||
"""Восстанавливает префикс из резервной копии."""
|
||||
backup_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Выберите файл резервной копии для восстановления",
|
||||
os.path.expanduser("~"),
|
||||
"WineHelper Backups (*.whpack);;Все файлы (*)",
|
||||
)
|
||||
|
||||
if not backup_path:
|
||||
return
|
||||
|
||||
backup_path = os.path.abspath(backup_path)
|
||||
|
||||
if not backup_path.lower().endswith(".whpack"):
|
||||
QMessageBox.critical(self, "Ошибка", "Выбранный файл не является резервной копией WineHelper (.whpack).")
|
||||
return
|
||||
|
||||
if not os.path.isfile(backup_path):
|
||||
QMessageBox.critical(self, "Ошибка", f"Файл резервной копии не найден:\n{backup_path}")
|
||||
return
|
||||
|
||||
def on_finished(context, exit_code, exit_status):
|
||||
self.gui.handle_restore_finished(context, exit_code, exit_status)
|
||||
|
||||
context = self.command_runner.create_dialog(
|
||||
f"Восстановление из: {os.path.basename(backup_path)}",
|
||||
self.winehelper_path,
|
||||
["restore-prefix", backup_path],
|
||||
finished_callback=on_finished,
|
||||
)
|
||||
context.dialog.exec()
|
||||
|
||||
def run_installed_app_with_debug(self):
|
||||
"""Запускает выбранное установленное приложение с созданием лога"""
|
||||
app_running = False
|
||||
if self.current_selected_app:
|
||||
app_running = self.app_launcher.is_app_running(self.current_selected_app.get("desktop_path", ""))
|
||||
if app_running:
|
||||
QMessageBox.information(
|
||||
self, "Приложение запущено", "Приложение уже запущено. Остановите его, прежде чем запускать с отладкой."
|
||||
)
|
||||
return
|
||||
|
||||
dialog = CreateLogDialog(self)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
|
||||
self._run_app_launcher(debug=True, winedebug_value=dialog.winedebug_value)
|
||||
|
||||
def open_app_additional_settings(self):
|
||||
"""Открывает диалог дополнительных настроек для выбранного приложения."""
|
||||
if not self.current_selected_app or "desktop_path" not in self.current_selected_app:
|
||||
QMessageBox.warning(self, "Ошибка", "Сначала выберите приложение.")
|
||||
return
|
||||
|
||||
desktop_path = self.current_selected_app["desktop_path"]
|
||||
exec_cmd = self.current_selected_app.get("exec", "")
|
||||
|
||||
exe_path = self._get_exe_path_from_desktop(desktop_path)
|
||||
|
||||
if not exe_path:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Ошибка",
|
||||
f"Не удалось найти исполняемый файл приложения.\n\n"
|
||||
f"Путь к .desktop: {desktop_path}\n"
|
||||
f"Exec: {exec_cmd}\n\n"
|
||||
f"Проверьте, что приложение корректно установлено.",
|
||||
)
|
||||
return
|
||||
|
||||
if not os.path.exists(exe_path):
|
||||
QMessageBox.warning(self, "Ошибка", f"Исполняемый файл не найден:\n{exe_path}")
|
||||
return
|
||||
|
||||
dialog = AppAdditionalSettingsDialog(exe_path, self)
|
||||
dialog.exec()
|
||||
|
||||
def _get_exe_path_from_desktop(self, desktop_path):
|
||||
"""Извлекает путь к .exe файлу из .desktop файла."""
|
||||
app_name = os.path.splitext(os.path.basename(desktop_path))[0]
|
||||
app_info = self.desktop_file_manager.read_desktop_file(app_name)
|
||||
exec_line = app_info.get("Exec", "")
|
||||
if not exec_line:
|
||||
return None
|
||||
|
||||
desktop_dir = os.path.dirname(desktop_path)
|
||||
return DesktopFileManager.get_exe_path_from_exec(exec_line, base_dir=desktop_dir)
|
||||
|
||||
def toggle_run_stop_app(self):
|
||||
"""Запускает или останавливает выбранное приложение."""
|
||||
if not self.current_selected_app:
|
||||
QMessageBox.warning(self, "Ошибка", "Сначала выберите приложение.")
|
||||
return
|
||||
if "desktop_path" not in self.current_selected_app:
|
||||
QMessageBox.warning(self, "Ошибка", "Сначала выберите приложение.")
|
||||
return
|
||||
|
||||
desktop_path = self.current_selected_app["desktop_path"]
|
||||
|
||||
# Если приложение запущено, останавливаем его
|
||||
if self.app_launcher.is_app_running(desktop_path):
|
||||
if self._is_stopping:
|
||||
return
|
||||
|
||||
process = self.app_launcher.get_process_for_app(desktop_path)
|
||||
if process and process.state() != QProcess.ProcessState.NotRunning:
|
||||
self._is_stopping = True
|
||||
self.run_button.setText("Ожидайте…")
|
||||
self.run_button.setStyleSheet(self.gui.WAIT_BUTTON_STYLE)
|
||||
self.run_button.setEnabled(False)
|
||||
|
||||
prefix_name = self.get_prefix_name_for_selected_app()
|
||||
if not prefix_name:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Ошибка",
|
||||
"Не удалось определить префикс для остановки приложения.\n"
|
||||
"Попробуйте закрыть приложение вручную.",
|
||||
)
|
||||
self._is_stopping = False
|
||||
self.run_button.setText("Остановить")
|
||||
self.run_button.setStyleSheet(self.gui.STOP_BUTTON_STYLE)
|
||||
self.run_button.setEnabled(True)
|
||||
ProcessUtils.stop_process(process, 1000)
|
||||
return
|
||||
|
||||
prefix_path = self.prefix_manager.get_prefix_path(prefix_name)
|
||||
wine_executable = self.gui.prefix_tab.get_wine_executable_for_prefix(prefix_name)
|
||||
|
||||
wineserver_path = os.path.join(os.path.dirname(wine_executable), "wineserver")
|
||||
if not shutil.which(wineserver_path):
|
||||
wineserver_path = "wineserver"
|
||||
|
||||
kill_proc = QProcess(self)
|
||||
ProcessUtils.prepare_process(kill_proc, {"WINEPREFIX": prefix_path})
|
||||
|
||||
def on_wineserver_finished(exit_code, exit_status):
|
||||
if exit_code == 0:
|
||||
self.install_runner.append_log(f"Приложения в префиксе '{prefix_name}' остановлены.")
|
||||
else:
|
||||
# wineserver -k даёт ненулевой код, когда wine в префиксе не запущен
|
||||
# (например, приложение уже завершилось, а обёртка создаёт архив лога) —
|
||||
# это не ошибка, кнопка вернётся в «Запустить» по завершении процесса приложения
|
||||
logger.warning(
|
||||
"wineserver -k завершился с кодом %d (статус: %s): wine в префиксе '%s' уже не запущен",
|
||||
exit_code,
|
||||
exit_status,
|
||||
prefix_name,
|
||||
)
|
||||
self.install_runner.append_log(
|
||||
f"wineserver -k завершился с кодом {exit_code}: "
|
||||
f"wine в префиксе '{prefix_name}' уже не запущен."
|
||||
)
|
||||
kill_proc.deleteLater()
|
||||
|
||||
def on_wineserver_error(error):
|
||||
if error == QProcess.ProcessError.FailedToStart:
|
||||
logger.error("Не удалось запустить wineserver: %s", wineserver_path)
|
||||
self.install_runner.append_log(
|
||||
f"Ошибка: не удалось запустить wineserver для префикса '{prefix_name}'."
|
||||
)
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Ошибка",
|
||||
"Не удалось запустить wineserver для остановки приложений.\n"
|
||||
"Проверьте, что Wine установлен в префиксе.",
|
||||
)
|
||||
self._restore_stop_state()
|
||||
kill_proc.deleteLater()
|
||||
|
||||
kill_proc.finished.connect(on_wineserver_finished)
|
||||
kill_proc.errorOccurred.connect(on_wineserver_error)
|
||||
|
||||
self.install_runner.append_log(f"Остановка приложений в префиксе '{prefix_name}'...")
|
||||
logger.debug("Остановка приложений в префиксе %s через wineserver -k", prefix_path)
|
||||
kill_proc.start(wineserver_path, ["-k"])
|
||||
else:
|
||||
# Состояние не совпадает, убираем из словаря
|
||||
logger.info("Процесс для %s уже не запущен, очистка.", desktop_path)
|
||||
self._on_app_process_finished(desktop_path)
|
||||
|
||||
# Если приложение не запущено, запускаем его
|
||||
else:
|
||||
# Запускаем без отладки. Кнопка отладки отдельная.
|
||||
self._run_app_launcher(debug=False)
|
||||
|
||||
def _on_app_process_finished(self, desktop_path):
|
||||
"""Обрабатывает завершение процесса запущенного приложения."""
|
||||
process = self.app_launcher.mark_app_finished(desktop_path)
|
||||
if process:
|
||||
process.deleteLater() # Clean up the QProcess object
|
||||
logger.info("Процесс для %s завершен.", desktop_path)
|
||||
|
||||
# Если текущее выбранное приложение - то, что только что завершилось, обновляем кнопку
|
||||
if self.current_selected_app and self.current_selected_app.get("desktop_path") == desktop_path:
|
||||
self._set_run_button_state(False)
|
||||
# Сбрасываем зелёную рамку на кнопке в сетке
|
||||
self._set_app_running_indicator(desktop_path, False)
|
||||
else:
|
||||
# Процесс уже убран из реестра: например, неудачный старт
|
||||
# очищает реестр в _on_start_error до сигнала finished
|
||||
logger.debug("Получен сигнал finished для неизвестного процесса %s", desktop_path)
|
||||
|
||||
def _set_app_running_indicator(self, desktop_path, running):
|
||||
"""Помечает кнопку приложения в сетке 'Установленные' зелёной рамкой (запущено) или сбрасывает её."""
|
||||
for btn in self.installed_app_buttons:
|
||||
if btn.property("desktop_path") == desktop_path:
|
||||
btn.setStyleSheet(self.gui.RUNNING_APP_BUTTON_STYLE if running else self.gui.INSTALLED_APP_BUTTON_STYLE)
|
||||
break
|
||||
|
||||
def _restore_stop_state(self) -> None:
|
||||
"""Возвращает кнопку Запустить/Остановить в состояние по фактическому статусу приложения."""
|
||||
self._is_stopping = False
|
||||
desktop_path = self.current_selected_app.get("desktop_path") if self.current_selected_app else None
|
||||
is_running = bool(desktop_path) and self.app_launcher.is_app_running(desktop_path)
|
||||
if is_running:
|
||||
self.run_button.setEnabled(True)
|
||||
self._set_run_button_state(is_running)
|
||||
|
||||
def _set_run_button_state(self, is_running):
|
||||
"""Устанавливает текст и стиль для кнопки Запустить/Остановить."""
|
||||
if is_running:
|
||||
self.run_button.setText("Остановить")
|
||||
self.run_button.setStyleSheet(self.gui.STOP_BUTTON_STYLE)
|
||||
self.create_log_button.setEnabled(False)
|
||||
self.backup_button.setEnabled(False)
|
||||
self.uninstall_button.setEnabled(False)
|
||||
self.restore_prefix_button_panel.setEnabled(False)
|
||||
self.app_additional_settings_button.setEnabled(False)
|
||||
else:
|
||||
self.run_button.setText("Запустить")
|
||||
self.run_button.setStyleSheet(self.gui.RUN_BUTTON_STYLE)
|
||||
self.run_button.setEnabled(True)
|
||||
self.create_log_button.setEnabled(True)
|
||||
self.backup_button.setEnabled(True)
|
||||
self.uninstall_button.setEnabled(True)
|
||||
self.restore_prefix_button_panel.setEnabled(True)
|
||||
self.app_additional_settings_button.setEnabled(True)
|
||||
# Скрываем индикатор создания лога
|
||||
self._is_stopping = False
|
||||
if self._debug_launch_active:
|
||||
self.log_status_label.setVisible(False)
|
||||
self.log_status_label.setText("Идёт создание и сохранение лога…")
|
||||
self.log_progress_bar.setVisible(False)
|
||||
self._debug_launch_active = False
|
||||
# Обновляем состояние кнопки лога
|
||||
self.update_open_log_dir_button_state()
|
||||
|
||||
def _run_app_launcher(self, debug=False, winedebug_value=None):
|
||||
"""Внутренний метод для запуска приложения (с отладкой или без) с использованием QProcess."""
|
||||
if not self.current_selected_app or "exec" not in self.current_selected_app:
|
||||
QMessageBox.warning(self, "Ошибка", "Сначала выберите приложение.")
|
||||
return
|
||||
|
||||
desktop_path = self.current_selected_app["desktop_path"]
|
||||
command_str = self.current_selected_app["exec"]
|
||||
|
||||
if self.app_launcher.is_app_running(desktop_path):
|
||||
logger.info("Приложение %s уже запущено.", self.current_selected_app.get("name"))
|
||||
return
|
||||
|
||||
try:
|
||||
command_parts = shlex.split(command_str)
|
||||
clean_command = [part for part in command_parts if not part.startswith("%")]
|
||||
|
||||
# Собираем переменные окружения из команды
|
||||
env_vars = {}
|
||||
cmd_start_index = 0
|
||||
if clean_command and clean_command[0] == "env":
|
||||
cmd_start_index = 1
|
||||
while cmd_start_index < len(clean_command) and "=" in clean_command[cmd_start_index]:
|
||||
key, value = clean_command[cmd_start_index].split("=", 1)
|
||||
env_vars[key] = value.strip("\"'")
|
||||
cmd_start_index += 1
|
||||
|
||||
if cmd_start_index >= len(clean_command):
|
||||
raise ValueError("Не найдена команда для выполнения в строке Exec.")
|
||||
|
||||
if debug:
|
||||
env_vars["WINEDEBUG"] = winedebug_value or DEFAULT_WINEDEBUG
|
||||
self._debug_launch_active = True
|
||||
|
||||
# Пропускаем проверку зависимостей при запуске уже установленного приложения
|
||||
env_vars["WH_SKIP_DEPS"] = "1"
|
||||
|
||||
# Формируем финальную команду как список аргументов
|
||||
final_command = clean_command[cmd_start_index:]
|
||||
|
||||
try:
|
||||
self.app_launcher.launch_app_by_command(
|
||||
desktop_path=desktop_path,
|
||||
command=final_command,
|
||||
env_vars=env_vars,
|
||||
parent=self,
|
||||
)
|
||||
# Подключаем сигналы завершения
|
||||
process = self.app_launcher.get_process_for_app(desktop_path)
|
||||
if process:
|
||||
process.finished.connect(partial(self._on_app_process_finished, desktop_path))
|
||||
process.finished.connect(self.update_open_log_dir_button_state)
|
||||
if self._debug_launch_active:
|
||||
|
||||
def _on_log_output():
|
||||
data = process.readAllStandardOutput().data().decode("utf-8", "ignore")
|
||||
if "Создаем сжатый архив" in data:
|
||||
self.log_status_label.setText("Идёт сжатие лога…")
|
||||
elif "Архив успешно создан" in data:
|
||||
self.log_status_label.setText("Сжатие лога завершено, сохранение…")
|
||||
|
||||
process.readyReadStandardOutput.connect(_on_log_output)
|
||||
else:
|
||||
# Сливаем вывод процесса: иначе буфер пайпа (~64 КиБ)
|
||||
# переполнится и приложение заблокируется на записи в stdout
|
||||
process.readyReadStandardOutput.connect(lambda: process.readAllStandardOutput())
|
||||
|
||||
self._set_run_button_state(True)
|
||||
if self._debug_launch_active:
|
||||
self.log_status_label.setVisible(True)
|
||||
self.log_progress_bar.setVisible(True)
|
||||
self._set_app_running_indicator(desktop_path, True)
|
||||
logger.info("Запущено: %s", " ".join(shlex.quote(part) for part in final_command))
|
||||
except Exception as e:
|
||||
logger.error("Не удалось запустить приложение %s: %s", desktop_path, e)
|
||||
QMessageBox.critical(
|
||||
self, "Ошибка запуска", f"Не удалось запустить команду:\n{command_str}\n\nОшибка: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Не удалось обработать команду запуска %s: %s", command_str, e)
|
||||
QMessageBox.critical(
|
||||
self, "Ошибка", f"Не удалось обработать команду запуска:\n{command_str}\n\nОшибка: {str(e)}"
|
||||
)
|
||||
|
||||
def uninstall_app(self):
|
||||
"""Удаляет выбранное установленное приложение и его префикс через winehelper."""
|
||||
if not self.current_selected_app or "desktop_path" not in self.current_selected_app:
|
||||
QMessageBox.warning(self, "Ошибка", "Сначала выберите приложение.")
|
||||
return
|
||||
|
||||
app_name = self.current_selected_app.get("name", "это приложение")
|
||||
prefix_name = self.get_prefix_name_for_selected_app()
|
||||
|
||||
if not prefix_name:
|
||||
QMessageBox.warning(self, "Ошибка", "Не удалось определить префикс для выбранного приложения.")
|
||||
return
|
||||
|
||||
if self.app_launcher.is_app_running(self.current_selected_app["desktop_path"]):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Приложение запущено",
|
||||
f'Невозможно удалить "{app_name}": приложение сейчас запущено.\n\n'
|
||||
"Сначала остановите его кнопкой «Остановить» и повторите удаление.",
|
||||
)
|
||||
return
|
||||
|
||||
if not DialogUtils.confirm(
|
||||
self,
|
||||
"Подтверждение",
|
||||
f'Вы действительно хотите удалить "{app_name}" и все связанные файлы (включая префикс "{prefix_name}")?',
|
||||
):
|
||||
return
|
||||
|
||||
context = self.command_runner.create_dialog(
|
||||
f"Удаление приложения: {app_name}",
|
||||
self.winehelper_path,
|
||||
["remove-prefix", prefix_name, "--force"],
|
||||
finished_callback=partial(self._handle_app_uninstall_finished, prefix_name),
|
||||
)
|
||||
context.dialog.exec()
|
||||
|
||||
def _handle_app_uninstall_finished(self, prefix_name, context, exit_code, exit_status):
|
||||
"""Обрабатывает завершение удаления приложения/префикса."""
|
||||
|
||||
def on_success():
|
||||
self.gui.prefix_tab.remove_prefix_from_gui_state(prefix_name)
|
||||
self.update_installed_apps()
|
||||
self.gui.reset_info_panel_to_default(TabNames.INSTALLED)
|
||||
|
||||
def on_error():
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"Ошибка удаления",
|
||||
f"Не удалось удалить приложение/префикс '{prefix_name}'.\nПодробности смотрите в логе.",
|
||||
)
|
||||
|
||||
self.command_runner.handle_finished(
|
||||
context, exit_code, exit_status, on_success_callback=on_success if exit_code == 0 else None
|
||||
)
|
||||
if exit_code != 0:
|
||||
on_error()
|
||||
|
||||
def filter_installed_buttons(self):
|
||||
"""Фильтрует кнопки установленных программ."""
|
||||
visible = self.gui.filter_buttons_in_grid(
|
||||
self.installed_search_edit.text(), self.installed_app_buttons, self.installed_scroll_layout
|
||||
)
|
||||
GridUtils.update_empty_state(
|
||||
self.empty_label, len(visible), self.installed_search_edit.text(), "Нет установленных приложений"
|
||||
)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Отображаемые имена вкладок главного окна WineHelper.
|
||||
|
||||
Единый источник строк для логики переключения вкладок: сравнения в
|
||||
обработчиках и вызовах add_tab используют только эти константы,
|
||||
чтобы переименование вкладки не ломало поведение молча.
|
||||
"""
|
||||
|
||||
|
||||
class TabNames:
|
||||
"""Имена вкладок главного окна (как они отображаются в TabBar)."""
|
||||
|
||||
AUTO_INSTALL = "Автоматическая установка"
|
||||
MANUAL_INSTALL = "Ручная установка"
|
||||
EXTERNAL_INSTALL = "Сторонняя установка"
|
||||
INSTALLED = "Установленные"
|
||||
PREFIX_MANAGER = "Менеджер префиксов"
|
||||
SETTINGS = "Настройки"
|
||||
HELP = "Справка"
|
||||
Reference in new issue
Block a user