Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
266302d643 | ||
|
|
38d3a43c03 | ||
|
|
c2c5a05013 | ||
|
|
410b24c9b5 | ||
|
|
a525b9f749 | ||
|
|
3c856cd890 | ||
|
|
c2bf75d0da | ||
|
|
50a2498abb | ||
|
|
53ec0f88bd | ||
|
|
b8cebe18b7 | ||
|
|
fe4473ef76 | ||
|
|
7c53112cc9 | ||
|
|
453b5a5a99 | ||
|
|
4990ac941a |
No files matched your search
@@ -70,6 +70,7 @@ 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 с информацией о префиксе
|
||||
@@ -78,9 +79,11 @@ winehelper/
|
||||
│ └── 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 приложений)
|
||||
@@ -130,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-скрипт с переменными:
|
||||
@@ -153,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 перед коммитом.
|
||||
|
||||
## Режимы работы
|
||||
|
||||
### Системный режим
|
||||
@@ -165,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 # Создать префикс
|
||||
@@ -189,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
|
||||
@@ -204,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`).
|
||||
|
||||
## Разработка
|
||||
|
||||
### Запуск в режиме разработки
|
||||
@@ -217,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/) для автоматической проверки кода перед коммитом.
|
||||
@@ -300,6 +362,7 @@ pre-commit autoupdate
|
||||
- Проверять наличие переменных перед использованием
|
||||
- Использовать функции обработки ошибок (`fatal`, `print_error`)
|
||||
- Экранировать все пути с помощью кавычек
|
||||
- Предпочитать готовые помощники (`try_remove_file`, `try_copy_dir`, `create_new_dir`, `try_download`) сырым `rm`/`cp`/`wget`
|
||||
|
||||
**Python:**
|
||||
- Импорты — только абсолютные (`from winehelperqt.x import y`). Относительные (`from .`, `from ..`) запрещены
|
||||
@@ -324,6 +387,14 @@ pre-commit autoupdate
|
||||
Вывод идёт в 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 ""`.
|
||||
|
||||
## Безопасность
|
||||
|
||||
- Скрипт запрещает запуск от root
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for Claude Code (claude.ai/code) working in this repo.
|
||||
|
||||
## What this is
|
||||
|
||||
WineHelper installs Windows apps on ALT Linux (p10+) via prepared install scripts, portable Wine builds, isolated prefixes. Two front-ends, one engine:
|
||||
|
||||
- `winehelper` — monolithic Bash CLI (~3100 lines). Engine. Everything (prefix creation, Wine download, DXVK/VKD3D, winetricks, .desktop files, backup/restore) = shell functions here.
|
||||
- `winehelper_gui.py` + `winehelperqt/` — PyQt5 GUI. No Wine logic of own; every action shells out to CLI via `QProcess`.
|
||||
|
||||
Docs, comments, log messages, UI strings in **Russian**. Keep so for new code. Commits follow Conventional Commits (`feat(gui): ...`, `refactor(gui): ...`, `fix(gui): ...`).
|
||||
|
||||
`AGENTS.md` (Russian) = more detailed guide, source for most of this file; keep both in sync when conventions change.
|
||||
|
||||
## Commands
|
||||
|
||||
No test suite. Verification = syntax checks + linters via pre-commit.
|
||||
|
||||
```bash
|
||||
# Run from the repo (dev mode — see below)
|
||||
./winehelper gui # GUI, foreground when run from a tty, log to stdout
|
||||
./winehelper install list # CLI
|
||||
./winehelper -i <script> # run an install script by file name (autoinstall/)
|
||||
./winehelper install <script> "/path/to/setup.exe" # manualinstall/ script: $2 = user's distributive
|
||||
./winehelper install "/path/to/script" # third-party script (mode "another"), e.g. a scratch copy for testing
|
||||
./winehelper install <script> --clear-pfx # ignore BASE_PFX, build prefix from scratch
|
||||
./winehelper run <exe_name> # run an installed program (prefix picked via .whdb); also: ./winehelper "/path/to/file.exe"
|
||||
./winehelper run-in-prefix <prefix> <file> # run arbitrary exe/msi in a given prefix
|
||||
./winehelper killall # kill every Wine process started from USER_WORK_PATH
|
||||
./winehelper --help # full subcommand list (wh_info)
|
||||
WH_LOG_LEVEL=debug ./winehelper gui # GUI log level: debug/info/warning/error
|
||||
WH_SKIP_DEPS=1 ./winehelper ... # skip the i586 dependency check (rpm -q of 32-bit libs)
|
||||
yes | ./winehelper install <script> # non-interactive: license prompt is `read -p`; with stdin not a tty the deps check is skipped too
|
||||
|
||||
# Lint / format (what pre-commit runs)
|
||||
pre-commit install # once
|
||||
pre-commit run --all-files
|
||||
ruff check --fix . && ruff format . # Python (line-length 120, rules E W F I ISC)
|
||||
shellcheck --severity=error --exclude=SC2068,SC2145,SC2199 winehelper autoinstall/* manualinstall/* testinstall/* hiden_scripts/* database/*.whdb
|
||||
shfmt --write --indent 4 --case-indent --space-redirects winehelper autoinstall/* ...
|
||||
bash -n winehelper autoinstall/* # syntax only
|
||||
scripts/check_sha256sum_list.sh # validates sha256sum.list format
|
||||
codespell --ignore-words .codespell-ignorewords
|
||||
```
|
||||
|
||||
CLI in dev mode runs `bash -n` over itself, `dependencies.sh`, every script in `autoinstall/`, `manualinstall/`, `testinstall/`, `database/`. Aborts on first syntax error.
|
||||
|
||||
Runtime facts worth knowing: `wine_run` maps `.msi` → `msiexec /i` (pass `/qn` for silent), `.exe` → `start /wait /high /unix` (var `WINE_WIN_START`); Wine output of every run is tee'd to `~/winehelper_backup_log/<prefix>_<exe>_<date>.log`. Downloaded installers are **not** checked against `sha256sum.list` (only Wine builds / base prefixes / DXVK are).
|
||||
|
||||
**Headless reproduction in podman** (used for bug #59578): `registry.altlinux.org/sisyphus/base` already has the `x86_64-i586` repo. As root: `apt-get install apt-repo xorg-xvfb xdotool ImageMagick winetricks 7-zip icoutils cabextract procps psmisc which`, then `bash dependencies.sh` (needs `apt-repo`), `useradd -m test`, `mkdir -m 1777 /tmp/.X11-unix`. Run the CLI as the non-root user (it refuses root) with the repo mounted read-only (dev mode → `~/test-winehelper/`), `Xvfb :99` + `DISPLAY=:99`, and **`LANG=ru_RU.UTF-8`** — without a UTF-8 locale MSI installs with Cyrillic paths roll back (`cabinet_copy_file ... error 3`) and app UIs render `?`. Screenshots: `import -window root out.png`; windows: `xdotool search --name ""`.
|
||||
|
||||
## Dev mode vs. installed mode
|
||||
|
||||
CLI detects run from `/usr/bin/winehelper` (system) vs repo (dev), exports different paths:
|
||||
|
||||
| | system | dev (repo) |
|
||||
|---|---|---|
|
||||
| data (`DATA_PATH`) | `/usr/share/winehelper/` | repo root |
|
||||
| user work dir (`USER_WORK_PATH`) | `~/.local/share/winehelper/` | `~/test-winehelper/` |
|
||||
| `WH_DEVEL` | unset | `1` |
|
||||
|
||||
Prefixes, downloaded Wine builds (`dist/`), temp files live under `USER_WORK_PATH`. `WH_DEVEL=1` also enables GUI "Settings" tab + dev icon. Dev runs never touch user's real install.
|
||||
|
||||
## Architecture: how the pieces talk
|
||||
|
||||
**CLI → GUI.** `./winehelper gui` exports env (`DATA_PATH`, `RUN_SCRIPT`, `USER_WORK_PATH`, `WH_DIST_DIR`, `WH_TMP_DIR`, `WH_DEVEL`, `WH_ICON_*`, `LICENSE_FILE`, `AGREEMENT`, `CHANGELOG_FILE`, …), then execs `python3 winehelper_gui.py`. GUI reads via `winehelperqt/utils/environment.py` (`EnvironmentConfig`). **GUI cannot start directly with `python3 winehelper_gui.py`** — needs that env.
|
||||
|
||||
**GUI → CLI.** Every GUI action (install, remove/backup/restore prefix, winetricks, run exe, killall…) builds `winehelper <command>` argv from `EnvironmentConfig.RUN_SCRIPT`, runs in `QProcess`, streams stdout to log dialog. Shared QProcess setup: `winehelperqt/utils/process_utils.py`; long-running command dialogs: `services/command_runner.py`, `services/install_runner.py`. CLI detects GUI spawn (`WH_USE_GUI=1` + parent process check), skips interactive license prompt.
|
||||
|
||||
**GUI package layout** (`winehelperqt/`): `widgets/` = main-window tabs (install / installed / prefix manager / help), `dialogs/` = modal dialogs, `services/` = business logic wrapping CLI, `parsers/` = readers for install scripts, `sha256sum.list`, winetricks output, `utils/` = env, logging, icons, grid layouts. Imports absolute only (`from winehelperqt.services.prefix_manager import PrefixManager`); no package-level re-exports, relative imports forbidden. Use `get_logger(__name__)` from `winehelperqt/logger.py`, never `print()`; `setup_logger()` called once in `main()`.
|
||||
|
||||
**CLI internals.** `winehelper` = env/path bootstrap → helpers (`fatal`, `print_*`, `try_download`, `unpack`, `check_file_sum`) → Wine/DXVK/prefix management (`init_wine_ver`, `init_wineprefix`, `prepair_wine`) → `wine_run` / `wine_run_install` → desktop integration (`create_desktop`) → big `case "$arg1"` dispatcher at bottom. Subcommands matched there; new ones go in that `case` + `wh_info` (`--help` text).
|
||||
|
||||
## Install scripts are sourced, not executed
|
||||
|
||||
Scripts in `autoinstall/`, `manualinstall/`, `testinstall/`, `hiden_scripts/` = Bash files **sourced by CLI** inside `run_autoinstall`. Call CLI functions directly, see CLI variables:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# info_ru: Описание программы ← parsed by CLI `install list` and by the GUI
|
||||
export PROG_URL="https://example.com"
|
||||
export WH_WINE_USE="wine-10.0-alt-tkg-wow64" # must exist in sha256sum.list
|
||||
export WINEPREFIX="prefix_name" # prefix name, not a path
|
||||
export PROG_NAME="Название" # parsed by the GUI
|
||||
export PROG_ICON="icon_name" # image/<icon_name>.png
|
||||
export BASE_PFX="base_pfx_v01" # or "none"
|
||||
export WINEARCH="win64"
|
||||
export INSTALL_DLL="corefonts msxml3 ..." # winetricks verbs
|
||||
export WH_WINDOWS_VER="10"
|
||||
|
||||
prepair_wine # downloads Wine, builds prefix, runs winetricks
|
||||
try_download "URL" "$WH_TMP_DIR/setup.exe"
|
||||
wine_run_install "$WH_TMP_DIR/setup.exe" /S
|
||||
create_desktop "$PROG_NAME" "$DRIVE_C/Vendor/app.exe" "$PROG_ICON"
|
||||
```
|
||||
|
||||
- `$1` = script name; `$2` = user-supplied installer path for `manualinstall/` scripts (must `fatal` if `$2` missing).
|
||||
- Directory decides mode: `autoinstall` (fully automated, shown in GUI), `manualinstall` (needs distributive), `testinstall` (experimental, GUI hides behind checkbox), `hiden_scripts` (not listed; runnable only by explicit path).
|
||||
- Every downloadable archive (Wine builds, base prefixes, DXVK/VKD3D) needs line in `sha256sum.list`; `check_file_sum` refuses unlisted.
|
||||
|
||||
## `.whdb` files and `last.conf`
|
||||
|
||||
Per-app runtime settings = Bash snippets, also sourced:
|
||||
|
||||
- `create_desktop` writes `<exe>.whdb` next to installed exe, copies script's `export` lines. Scripts may append extra Bash (hooks, background tasks) to that file, like `autoinstall/ksamu`.
|
||||
- `database/*.whdb` = shared profiles matched by `#name.exe` comment line when no per-exe `.whdb` (e.g. T-FLEX).
|
||||
- `init_database` sources matched `.whdb`, then `$WINEPREFIX/last.conf` (last-used settings of that prefix) before every `wine_run`. `last.conf` = what GUI "prefix settings" dialog edits (`WH_WINDOWS_VER`, `WH_MAIN_DECORATED`, `WH_VIRTUAL_DESKTOP`, `WH_XDG_OPEN`, `WH_USE_CPCSP_PROXY`, …).
|
||||
|
||||
All `source`d → syntax error in script or `.whdb` breaks CLI. Run `bash -n` / shellcheck before commit.
|
||||
|
||||
## Coding rules worth knowing
|
||||
|
||||
- Bash: `#!/usr/bin/env bash`, quote every path, fail via `fatal`, prefer existing helpers (`try_remove_file`, `try_copy_dir`, `create_new_dir`, `try_download`) over raw `rm`/`cp`/`wget`.
|
||||
- Python: PyQt5 only (no other GUI deps), Russian docstrings, type hints for new code, classes subclass appropriate `QWidget`. Delete finished `QProcess` objects (see `utils/process_utils.py`) — leaks were real bug.
|
||||
- No deps beyond PyQt5 + stdlib; ships as ALT Linux RPM, CLI installs system deps via `dependencies.sh` / apt-get.
|
||||
- Words codespell flags but valid → `.codespell-ignorewords`.
|
||||
+1
-1
@@ -6,7 +6,7 @@ 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_v01"
|
||||
export BASE_PFX="adem_pfx_x64_v02"
|
||||
export WINEARCH="win64"
|
||||
export WH_WINDOWS_VER="10"
|
||||
export INSTALL_DLL="dotnet48 vcrun2008 vcrun2010 vcrun2012 vcrun2022 corefonts"
|
||||
|
||||
+3
-1
@@ -1,4 +1,5 @@
|
||||
##### 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
|
||||
@@ -224,9 +225,10 @@ 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
|
||||
|
||||
5a7ca1e4dda57db0929ad4d042c77b2a47c4c0bfb9dbd9e44eea95d3a43c5989 adem_pfx_x64_v01.tar.xz
|
||||
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
|
||||
|
||||
+47
-3
@@ -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}"
|
||||
@@ -1169,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"
|
||||
@@ -1401,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
|
||||
@@ -1674,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
|
||||
|
||||
|
||||
+112
-107
@@ -22,8 +22,6 @@ from winehelperqt.qt_compat import (
|
||||
QCursor,
|
||||
QDialog,
|
||||
QFileDialog,
|
||||
QFont,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QIcon,
|
||||
@@ -38,7 +36,6 @@ from winehelperqt.qt_compat import (
|
||||
QProcess,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QStackedWidget,
|
||||
QSystemTrayIcon,
|
||||
Qt,
|
||||
@@ -60,13 +57,16 @@ from winehelperqt.services.settings_manager import SettingsManager
|
||||
from winehelperqt.utils.cache_service import CacheService
|
||||
from winehelperqt.utils.dialog_utils import DialogUtils
|
||||
from winehelperqt.utils.environment import EnvironmentConfig
|
||||
from winehelperqt.utils.grid_utils import GridUtils
|
||||
from winehelperqt.utils.font_utils import app_font
|
||||
from winehelperqt.utils.grid_utils import GridUtils, SearchableTabContent
|
||||
from winehelperqt.utils.log_handler import MultiLogHandler
|
||||
from winehelperqt.widgets.help_tab import HelpTab
|
||||
from winehelperqt.widgets.icon_manager import IconManager
|
||||
from winehelperqt.widgets.info_panel import InfoPanelController
|
||||
from winehelperqt.widgets.install_tab import ExternalInstallTab, InstallTab
|
||||
from winehelperqt.widgets.installed_tab import InstalledTab
|
||||
from winehelperqt.widgets.prefix_tab import PrefixTab
|
||||
from winehelperqt.widgets.tab_names import TabNames
|
||||
|
||||
logger = get_logger("winehelper_gui")
|
||||
|
||||
@@ -148,7 +148,9 @@ class WineHelperGUI(QMainWindow):
|
||||
# Основные переменные
|
||||
self.winehelper_path = EnvironmentConfig.RUN_SCRIPT
|
||||
self.current_script = None
|
||||
self.current_script_path = None # Полный путь к выбранному скрипту (резолвит show_script_info)
|
||||
self.current_display_name = None
|
||||
self._license_accepted = False # Соглашение принято (на сессию, как в CLI — на процесс)
|
||||
self._exit_in_progress = False # Идёт завершение (ждём killall)
|
||||
self._killall_done = False # Процесс killall завершился
|
||||
|
||||
@@ -159,7 +161,6 @@ class WineHelperGUI(QMainWindow):
|
||||
# Состояние менеджера префиксов
|
||||
self.prefixes_before_install = set()
|
||||
|
||||
self.is_quitting = False # Флаг для корректного выхода из приложения
|
||||
self.log_handler = MultiLogHandler() # Единый обработчик логов
|
||||
# Контроллер диалога и процесса установки приложений
|
||||
self.install_runner = InstallRunner(self, self.log_handler)
|
||||
@@ -193,20 +194,20 @@ class WineHelperGUI(QMainWindow):
|
||||
|
||||
# Вкладки
|
||||
self.auto_install_tab = InstallTab(self, "auto", ["autoinstall"], "Поиск скрипта автоматической установки...")
|
||||
self.add_tab(self.auto_install_tab, "Автоматическая установка")
|
||||
self.add_tab(self.auto_install_tab, TabNames.AUTO_INSTALL)
|
||||
self.manual_install_tab = InstallTab(self, "manual", ["manualinstall"], "Поиск скрипта ручной установки...")
|
||||
self.add_tab(self.manual_install_tab, "Ручная установка")
|
||||
self.add_tab(self.manual_install_tab, TabNames.MANUAL_INSTALL)
|
||||
self.external_install_tab = ExternalInstallTab(self)
|
||||
self.add_tab(self.external_install_tab, "Сторонняя установка")
|
||||
self.add_tab(self.external_install_tab, TabNames.EXTERNAL_INSTALL)
|
||||
self.installed_tab = InstalledTab(self)
|
||||
self.add_tab(self.installed_tab, "Установленные")
|
||||
self.add_tab(self.installed_tab, TabNames.INSTALLED)
|
||||
self.prefix_tab = PrefixTab(self)
|
||||
self.add_tab(self.prefix_tab, "Менеджер префиксов")
|
||||
self.add_tab(self.prefix_tab, TabNames.PREFIX_MANAGER)
|
||||
# Вкладка «Настройки» доступна только в режиме разработки
|
||||
# (тестовый запуск WineHelper из репозитория)
|
||||
if EnvironmentConfig.WH_DEVEL == "1":
|
||||
self.create_settings_tab()
|
||||
self.add_tab(HelpTab(self), "Справка")
|
||||
self.add_tab(HelpTab(self), TabNames.HELP)
|
||||
|
||||
# Загружаем состояние после создания всех виджетов
|
||||
self.prefix_tab.load_created_prefixes()
|
||||
@@ -249,6 +250,10 @@ class WineHelperGUI(QMainWindow):
|
||||
if version:
|
||||
BaseVersionSelectionDialog.set_system_wine_version(version)
|
||||
logger.debug("Кэш версии системного wine заполнен: %s", version)
|
||||
else:
|
||||
# Кэшируем и неудачу, чтобы диалог выбора версии не запускал
|
||||
# повторные wine --version при каждом открытии
|
||||
BaseVersionSelectionDialog.set_system_wine_version("")
|
||||
proc.deleteLater()
|
||||
except RuntimeError:
|
||||
pass
|
||||
@@ -338,9 +343,9 @@ class WineHelperGUI(QMainWindow):
|
||||
|
||||
def reset_info_panel_to_default(self, tab_name):
|
||||
"""Сбрасывает правую панель в состояние по умолчанию для указанной вкладки."""
|
||||
if tab_name == "Автоматическая установка":
|
||||
if tab_name == TabNames.AUTO_INSTALL:
|
||||
html_content = (
|
||||
"<h3>Автоматическая установка</h3>"
|
||||
f"<h3>{TabNames.AUTO_INSTALL}</h3>"
|
||||
"<p>Скрипты из этого списка скачают, установят и настроят "
|
||||
"приложение за вас. Просто выберите программу и нажмите "
|
||||
"«Установить».</p>"
|
||||
@@ -348,18 +353,18 @@ class WineHelperGUI(QMainWindow):
|
||||
"отметьте опцию <b>«Показать тестовые версии»</b> внизу списка.</p>"
|
||||
)
|
||||
show_global = False
|
||||
elif tab_name == "Ручная установка":
|
||||
elif tab_name == TabNames.MANUAL_INSTALL:
|
||||
html_content = (
|
||||
"<h3>Ручная установка</h3>"
|
||||
f"<h3>{TabNames.MANUAL_INSTALL}</h3>"
|
||||
"<p>Эти скрипты подготовят окружение для установки.</p>"
|
||||
"<p>Вам нужно будет указать только путь к установочному файлу "
|
||||
"(<code>.exe</code> или <code>.msi</code>), который вы скачали "
|
||||
"самостоятельно и нажать «Установить».</p>"
|
||||
)
|
||||
show_global = False
|
||||
elif tab_name == "Сторонняя установка":
|
||||
elif tab_name == TabNames.EXTERNAL_INSTALL:
|
||||
html_content = (
|
||||
"<h3>Сторонняя установка</h3>"
|
||||
f"<h3>{TabNames.EXTERNAL_INSTALL}</h3>"
|
||||
"<p>Здесь можно установить программу из собственного скрипта установки "
|
||||
"(bash-скрипт с переменными <code>PROG_NAME</code>, <code>WINEPREFIX</code>, "
|
||||
"<code>WH_WINE_USE</code> и др.).</p>"
|
||||
@@ -371,7 +376,7 @@ class WineHelperGUI(QMainWindow):
|
||||
"без шаблона: занимает больше времени, так как все компоненты ставятся заново.</p>"
|
||||
)
|
||||
show_global = False
|
||||
elif tab_name == "Установленные":
|
||||
elif tab_name == TabNames.INSTALLED:
|
||||
html_content = (
|
||||
"<h3>Установленные программы</h3>"
|
||||
"<p>Здесь отображаются все приложения, установленные с помощью "
|
||||
@@ -386,9 +391,9 @@ class WineHelperGUI(QMainWindow):
|
||||
|
||||
# Заголовок скрыт: название вкладки уже указано в <h3> описания,
|
||||
# он появляется только при выборе конкретной программы
|
||||
self.script_title.setVisible(False)
|
||||
self.script_description.setHtml(html_content)
|
||||
self.script_description.setVisible(True)
|
||||
self.info_panel.set_title_visible(False)
|
||||
self.info_panel.set_description(html_content)
|
||||
self.info_panel.set_description_visible(True)
|
||||
self.install_action_widget.setVisible(False)
|
||||
self.manual_install_path_widget.setVisible(False)
|
||||
# Виджеты вкладки «Установленные» управляются самой вкладкой
|
||||
@@ -399,11 +404,11 @@ class WineHelperGUI(QMainWindow):
|
||||
# Очищаем поле поиска на вкладке, которую покинули, по явной ссылке
|
||||
previous_tab_text = self.tab_bar.tabText(self.previous_tab_index)
|
||||
search_edit = None
|
||||
if previous_tab_text == "Автоматическая установка":
|
||||
if previous_tab_text == TabNames.AUTO_INSTALL:
|
||||
search_edit = self.auto_install_tab.search_edit
|
||||
elif previous_tab_text == "Ручная установка":
|
||||
elif previous_tab_text == TabNames.MANUAL_INSTALL:
|
||||
search_edit = self.manual_install_tab.search_edit
|
||||
elif previous_tab_text == "Установленные":
|
||||
elif previous_tab_text == TabNames.INSTALLED:
|
||||
search_edit = getattr(self.installed_tab, "installed_search_edit", None)
|
||||
if search_edit:
|
||||
search_edit.clear()
|
||||
@@ -414,53 +419,27 @@ class WineHelperGUI(QMainWindow):
|
||||
current_tab_text = self.tab_bar.tabText(index)
|
||||
|
||||
# Сбрасываем растяжение к состоянию по умолчанию:
|
||||
# растягивается виджет с описанием (индекс 1), а не виджет с действиями (индекс 4)
|
||||
self.info_panel_layout.setStretch(1, 1)
|
||||
self.info_panel_layout.setStretch(4, 0)
|
||||
# растягивается виджет с описанием, а не виджет с действиями
|
||||
self.info_panel.reset_stretch()
|
||||
|
||||
if current_tab_text in ["Справка", "Менеджер префиксов", "Настройки"]:
|
||||
self.info_panel.setVisible(False)
|
||||
if current_tab_text in [TabNames.HELP, TabNames.PREFIX_MANAGER, TabNames.SETTINGS]:
|
||||
self.info_panel.set_visible(False)
|
||||
else:
|
||||
self.info_panel.setVisible(True)
|
||||
self.info_panel.set_visible(True)
|
||||
self.reset_info_panel_to_default(current_tab_text)
|
||||
if current_tab_text == "Установленные":
|
||||
if current_tab_text == TabNames.INSTALLED:
|
||||
self.installed_tab.filter_installed_buttons()
|
||||
|
||||
def create_info_panel(self):
|
||||
"""Создает правую панель с информацией о скрипте"""
|
||||
self.info_panel = QFrame()
|
||||
self.info_panel.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
self.info_panel.setMinimumWidth(400) # Размер инф. панели
|
||||
self.info_panel.setFont(QFont("Arial", 10)) # Шрифт и размер шрифта в инф. панели
|
||||
self.info_panel_layout = QVBoxLayout()
|
||||
self.info_panel.setLayout(self.info_panel_layout)
|
||||
"""Создаёт контроллер правой информационной панели и его секции."""
|
||||
self.info_panel = InfoPanelController(self)
|
||||
|
||||
# Создаём секции панели
|
||||
self._create_script_info_section()
|
||||
self._create_manual_install_path_section()
|
||||
self._create_install_actions_section()
|
||||
|
||||
# Изначально скрыть все виджеты действий
|
||||
self.install_action_widget.setVisible(False)
|
||||
|
||||
def _create_script_info_section(self):
|
||||
"""Создаёт секцию с заголовком и описанием скрипта."""
|
||||
# Заголовок
|
||||
self.script_title = QLabel("Выберите программу")
|
||||
self.script_title.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
||||
self.script_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
# Заголовок не растягивается по вертикали, чтобы кнопка "Запустить" поднялась
|
||||
self.script_title.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
||||
# Изначально скрыт: появляется только при выборе программы
|
||||
self.script_title.setVisible(False)
|
||||
self.info_panel_layout.addWidget(self.script_title)
|
||||
|
||||
# Описание
|
||||
self.script_description = QTextBrowser()
|
||||
self.script_description.setReadOnly(True)
|
||||
self.script_description.setOpenExternalLinks(True)
|
||||
self.info_panel_layout.addWidget(self.script_description)
|
||||
|
||||
def _create_manual_install_path_section(self):
|
||||
"""Создаёт секцию для ввода пути установочного файла."""
|
||||
self.manual_install_path_layout = QVBoxLayout()
|
||||
@@ -480,7 +459,7 @@ class WineHelperGUI(QMainWindow):
|
||||
self.manual_install_path_widget = QWidget()
|
||||
self.manual_install_path_widget.setLayout(self.manual_install_path_layout)
|
||||
self.manual_install_path_widget.setVisible(False)
|
||||
self.info_panel_layout.addWidget(self.manual_install_path_widget)
|
||||
self.info_panel.add_section(self.manual_install_path_widget)
|
||||
|
||||
def _create_install_actions_section(self):
|
||||
"""Создаёт секцию с кнопками действий установщика."""
|
||||
@@ -488,12 +467,12 @@ class WineHelperGUI(QMainWindow):
|
||||
install_action_layout = QVBoxLayout()
|
||||
install_action_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.install_button = QPushButton("Установить")
|
||||
self.install_button.setFont(QFont("Arial", 12, QFont.Weight.Bold))
|
||||
self.install_button.setFont(app_font(12, bold=True))
|
||||
self.install_button.setStyleSheet("background-color: #4CAF50; color: white;")
|
||||
self.install_button.clicked.connect(self.install_current_script)
|
||||
install_action_layout.addWidget(self.install_button)
|
||||
self.install_action_widget.setLayout(install_action_layout)
|
||||
self.info_panel_layout.addWidget(self.install_action_widget)
|
||||
self.info_panel.add_section(self.install_action_widget)
|
||||
|
||||
def set_installer_action_visible(self, visible):
|
||||
"""Показывает или скрывает секцию действий установщика (виджет окна)."""
|
||||
@@ -516,11 +495,13 @@ class WineHelperGUI(QMainWindow):
|
||||
|
||||
def create_searchable_grid_tab(
|
||||
self, placeholder_text, filter_slot, add_stretch=True, with_description_search=False
|
||||
):
|
||||
) -> SearchableTabContent:
|
||||
"""
|
||||
Создает стандартную вкладку с полем поиска и сеточным макетом с прокруткой.
|
||||
Возвращает кортеж (главный виджет вкладки, сеточный макет, поле поиска, область прокрутки,
|
||||
чекбокс поиска по описанию или None).
|
||||
|
||||
Returns:
|
||||
SearchableTabContent с виджетом вкладки, сеткой, полем поиска,
|
||||
областью прокрутки, чекбоксом поиска по описанию (или None) и меткой пустого состояния
|
||||
"""
|
||||
tab_widget = QWidget()
|
||||
layout = QVBoxLayout(tab_widget)
|
||||
@@ -594,12 +575,19 @@ class WineHelperGUI(QMainWindow):
|
||||
if add_stretch:
|
||||
v_scroll_layout.addStretch(1)
|
||||
|
||||
return tab_widget, grid_layout, search_edit, scroll_area, desc_checkbox, empty_label
|
||||
return SearchableTabContent(
|
||||
widget=tab_widget,
|
||||
grid_layout=grid_layout,
|
||||
search_edit=search_edit,
|
||||
scroll_area=scroll_area,
|
||||
description_checkbox=desc_checkbox,
|
||||
empty_label=empty_label,
|
||||
)
|
||||
|
||||
def create_settings_tab(self):
|
||||
"""Создаёт вкладку глобальных настроек приложения."""
|
||||
settings_widget = GlobalSettingsWidget(self)
|
||||
self.add_tab(settings_widget, "Настройки")
|
||||
self.add_tab(settings_widget, TabNames.SETTINGS)
|
||||
|
||||
def set_active_button(self, button_widget):
|
||||
"""Устанавливает активную кнопку и обновляет стили ее обертки (QFrame)."""
|
||||
@@ -664,6 +652,11 @@ class WineHelperGUI(QMainWindow):
|
||||
remove_all_process = QProcess(self)
|
||||
|
||||
def on_remove_all_finished(exit_code, exit_status):
|
||||
# Окно может быть закрыто, пока шёл remove-all: без guard
|
||||
# обращения к виджетам ниже дали бы RuntimeError
|
||||
if DialogUtils.is_widget_deleted(self):
|
||||
logger.debug("remove-all завершился после закрытия окна, обработка пропущена")
|
||||
return
|
||||
if exit_code != 0:
|
||||
logger.error("Команда remove-all завершилась с кодом %d (статус: %s)", exit_code, exit_status)
|
||||
QMessageBox.critical(self, "Ошибка", f"Команда удаления завершилась с кодом {exit_code}.")
|
||||
@@ -682,7 +675,6 @@ class WineHelperGUI(QMainWindow):
|
||||
|
||||
def quit_application(self):
|
||||
"""Инициирует процесс выхода из приложения."""
|
||||
self.is_quitting = True
|
||||
self.close() # Инициируем событие закрытия, которое будет обработано в closeEvent
|
||||
|
||||
def closeEvent(self, event):
|
||||
@@ -708,6 +700,12 @@ class WineHelperGUI(QMainWindow):
|
||||
icon=QMessageBox.Icon.Question,
|
||||
):
|
||||
self._exit_in_progress = True
|
||||
# Сразу блокируем интерфейс: до завершения killall окно неактивно,
|
||||
# чтобы пользователь не мог запустить установку, которую оборвёт
|
||||
# выход из приложения
|
||||
self.main_widget.setEnabled(False)
|
||||
self.setWindowTitle("WineHelper — завершение работы…")
|
||||
QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
|
||||
self.command_runner.terminate_process()
|
||||
self.prefix_tab.kill_all_wine_util_processes()
|
||||
|
||||
@@ -725,18 +723,22 @@ class WineHelperGUI(QMainWindow):
|
||||
logger.debug("Запуск winehelper killall")
|
||||
killall_process = QProcess(self)
|
||||
|
||||
# Выход через exit(0), а не quit(): в Qt6 после проигнорированного
|
||||
# события закрытия (event.ignore() ниже) вызов quit() не завершает
|
||||
# цикл событий (проверено на Qt 6.11) — окно остаётся висеть.
|
||||
# В Qt5 оба варианта эквивалентны, exit(0) работает в обоих binding'ах.
|
||||
def on_killall_finished(exit_code, exit_status):
|
||||
self._killall_done = True
|
||||
if exit_code != 0:
|
||||
logger.error("Команда killall завершилась с кодом %d (статус: %s)", exit_code, exit_status)
|
||||
QApplication.instance().quit()
|
||||
QApplication.instance().exit(0)
|
||||
|
||||
def on_killall_error(error):
|
||||
# Если killall не смог стартовать, сигнала finished не будет —
|
||||
# выходим принудительно, чтобы окно не осталось незакрытым
|
||||
if error == QProcess.ProcessError.FailedToStart:
|
||||
logger.error("Не удалось запустить winehelper killall, принудительный выход")
|
||||
QApplication.instance().quit()
|
||||
QApplication.instance().exit(0)
|
||||
|
||||
def on_killall_timeout():
|
||||
# Страховка от зависшего killall: не держим окно навсегда
|
||||
@@ -745,7 +747,7 @@ class WineHelperGUI(QMainWindow):
|
||||
"winehelper killall не завершился за %d мс, принудительный выход",
|
||||
self.KILLALL_TIMEOUT_MS,
|
||||
)
|
||||
QApplication.instance().quit()
|
||||
QApplication.instance().exit(0)
|
||||
|
||||
killall_process.finished.connect(on_killall_finished)
|
||||
killall_process.errorOccurred.connect(on_killall_error)
|
||||
@@ -758,7 +760,7 @@ class WineHelperGUI(QMainWindow):
|
||||
self.command_runner.terminate_process()
|
||||
self.prefix_tab.kill_all_wine_util_processes()
|
||||
|
||||
QApplication.instance().quit()
|
||||
QApplication.instance().exit(0)
|
||||
|
||||
def filter_buttons_in_grid(self, search_text, button_list, grid_layout, text_getter=None):
|
||||
"""Общий метод для фильтрации кнопок и перестроения сетки (helper)."""
|
||||
@@ -774,6 +776,7 @@ class WineHelperGUI(QMainWindow):
|
||||
layout = QVBoxLayout(dialog)
|
||||
|
||||
license_text = QTextBrowser()
|
||||
license_loaded = True
|
||||
try:
|
||||
license_file_path = EnvironmentConfig.LICENSE_AGREEMENT_FILE
|
||||
if not license_file_path or not os.path.exists(license_file_path):
|
||||
@@ -789,6 +792,7 @@ class WineHelperGUI(QMainWindow):
|
||||
f"{escaped_license_content}</pre>"
|
||||
)
|
||||
except (FileNotFoundError, TypeError) as e:
|
||||
license_loaded = False
|
||||
logger.warning("Не удалось загрузить соглашение из %s: %s", license_file_path, e)
|
||||
license_text.setHtml(
|
||||
"<h3>Лицензионные соглашения</h3>"
|
||||
@@ -796,6 +800,7 @@ class WineHelperGUI(QMainWindow):
|
||||
f"{EnvironmentConfig.LICENSE_AGREEMENT_FILE}</p>"
|
||||
)
|
||||
except Exception as e:
|
||||
license_loaded = False
|
||||
logger.error("Ошибка чтения соглашения %s: %s", license_file_path, e)
|
||||
license_text.setHtml(
|
||||
f"<h3>Лицензионные соглашения</h3><p>Произошла ошибка при чтении файла лицензии:<br>{str(e)}</p>"
|
||||
@@ -823,14 +828,27 @@ class WineHelperGUI(QMainWindow):
|
||||
lambda state: accept_button.setEnabled(Qt.CheckState(state) == Qt.CheckState.Checked)
|
||||
)
|
||||
|
||||
if not license_loaded:
|
||||
checkbox.setEnabled(False)
|
||||
|
||||
return dialog.exec() == QDialog.DialogCode.Accepted
|
||||
|
||||
def ensure_license_accepted(self):
|
||||
if not self._license_accepted:
|
||||
if not self.show_license_agreement_dialog():
|
||||
return False
|
||||
self._license_accepted = True
|
||||
return True
|
||||
|
||||
def install_current_script(self):
|
||||
"""Устанавливает текущий выбранный скрипт"""
|
||||
if not self.current_script:
|
||||
QMessageBox.warning(self, "Ошибка", "Не выбрана программа для установки")
|
||||
return
|
||||
|
||||
# Путь к установочному файлу проверяем здесь, единственный раз:
|
||||
# _prepare_installation получает уже валидированный install_file
|
||||
install_file = None
|
||||
if self.current_script in self.manual_install_tab.scripts:
|
||||
install_file = self.install_path_edit.text().strip()
|
||||
if not install_file:
|
||||
@@ -840,61 +858,47 @@ class WineHelperGUI(QMainWindow):
|
||||
QMessageBox.warning(self, "Ошибка", f"Установочный файл не найден:\n{install_file}")
|
||||
return
|
||||
|
||||
if not self.show_license_agreement_dialog():
|
||||
return # Пользователь отклонил лицензию
|
||||
if not self.ensure_license_accepted():
|
||||
return
|
||||
|
||||
self.prefixes_before_install = self.prefix_tab.get_current_prefixes()
|
||||
self.install_runner.create_dialog(self._get_current_app_title())
|
||||
# Сразу же готовим и запускаем установку
|
||||
self._prepare_installation()
|
||||
self._prepare_installation(install_file)
|
||||
|
||||
def _prepare_installation(self):
|
||||
def _prepare_installation(self, install_file=None):
|
||||
"""Подготавливает и запускает процесс установки"""
|
||||
self.install_runner.reset_log_state() # Сбрасываем состояние для обработки лога
|
||||
|
||||
winehelper_path = self.winehelper_path
|
||||
autoinstall_path = os.path.join(EnvironmentConfig.DATA_PATH, "autoinstall", self.current_script)
|
||||
testinstall_path = os.path.join(EnvironmentConfig.DATA_PATH, "testinstall", self.current_script)
|
||||
if os.path.exists(autoinstall_path):
|
||||
script_folder = "autoinstall"
|
||||
elif os.path.exists(testinstall_path):
|
||||
script_folder = "testinstall"
|
||||
else:
|
||||
script_folder = "manualinstall"
|
||||
script_path = os.path.join(EnvironmentConfig.DATA_PATH, script_folder, self.current_script)
|
||||
# Полный путь к скрипту уже резолвил show_script_info (current_script_path),
|
||||
# папку скрипта (autoinstall/testinstall/manualinstall) угадывать повторно не нужно
|
||||
script_path = self.current_script_path
|
||||
|
||||
if not os.path.exists(winehelper_path):
|
||||
QMessageBox.critical(
|
||||
self.install_runner.dialog, "Ошибка", f"winehelper не найден по пути:\n{winehelper_path}"
|
||||
)
|
||||
return
|
||||
if not os.path.exists(script_path):
|
||||
if not script_path or not os.path.exists(script_path):
|
||||
QMessageBox.critical(self.install_runner.dialog, "Ошибка", f"Скрипт установки не найден:\n{script_path}")
|
||||
return
|
||||
|
||||
if self.current_script in self.manual_install_tab.scripts:
|
||||
install_file = self.install_path_edit.text().strip()
|
||||
if not install_file:
|
||||
QMessageBox.critical(self.install_runner.dialog, "Ошибка", "Не указан путь к установочному файлу")
|
||||
return
|
||||
if not os.path.isfile(install_file):
|
||||
QMessageBox.critical(
|
||||
self.install_runner.dialog, "Ошибка", f"Установочный файл не найден:\n{install_file}"
|
||||
)
|
||||
return
|
||||
QTimer.singleShot(100, lambda: self.install_runner.start(winehelper_path, script_path, install_file))
|
||||
else:
|
||||
QTimer.singleShot(100, lambda: self.install_runner.start(winehelper_path, script_path))
|
||||
QTimer.singleShot(100, lambda: self.install_runner.start(winehelper_path, script_path, install_file))
|
||||
|
||||
def _on_installation_finished(self, exit_code, exit_status, cancelled):
|
||||
"""Обновляет списки префиксов и приложений после завершения установки."""
|
||||
if cancelled or exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
|
||||
return
|
||||
|
||||
# Установка завершилась успешно — очищаем путь ручной установки,
|
||||
# чтобы при следующей установке не остался старый путь
|
||||
self.install_path_edit.clear()
|
||||
|
||||
# --- Обновление списка префиксов ---
|
||||
# Определяем, какой префикс был создан
|
||||
prefixes_after_install = self.prefix_tab.get_current_prefixes()
|
||||
new_prefixes = prefixes_after_install - getattr(self, "prefixes_before_install", set())
|
||||
new_prefixes = prefixes_after_install - self.prefixes_before_install
|
||||
|
||||
# Перезагружаем список префиксов на вкладке "Менеджер префиксов"
|
||||
self.prefix_tab.load_created_prefixes()
|
||||
@@ -916,20 +920,20 @@ class WineHelperGUI(QMainWindow):
|
||||
|
||||
self.installed_tab.update_installed_apps()
|
||||
|
||||
def handle_launcher_creation_finished(self, exit_code, exit_status):
|
||||
def handle_launcher_creation_finished(self, context, exit_code, exit_status):
|
||||
"""Обрабатывает завершение создания ярлыка."""
|
||||
|
||||
def on_success():
|
||||
self.installed_tab.update_installed_apps()
|
||||
# Переключаемся на вкладку "Установленные"
|
||||
for i in range(self.tab_bar.count()):
|
||||
if self.tab_bar.tabText(i) == "Установленные":
|
||||
if self.tab_bar.tabText(i) == TabNames.INSTALLED:
|
||||
self.tab_bar.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
self.command_runner.handle_finished(exit_code, exit_status, on_success_callback=on_success)
|
||||
self.command_runner.handle_finished(context, exit_code, exit_status, on_success_callback=on_success)
|
||||
|
||||
def handle_restore_finished(self, exit_code, exit_status):
|
||||
def handle_restore_finished(self, context, exit_code, exit_status):
|
||||
"""Обрабатывает завершение для диалога команды восстановления."""
|
||||
|
||||
def on_success():
|
||||
@@ -938,6 +942,7 @@ class WineHelperGUI(QMainWindow):
|
||||
self.installed_tab.filter_installed_buttons()
|
||||
|
||||
self.command_runner.handle_finished(
|
||||
context,
|
||||
exit_code,
|
||||
exit_status,
|
||||
success_message="\n=== Восстановление успешно завершено ===",
|
||||
@@ -961,10 +966,11 @@ def main():
|
||||
|
||||
# Включаем поддержку HiDPI до создания QApplication для надежного и предсказуемого масштабирования.
|
||||
# AA_EnableHighDpiScaling включает автоматическое масштабирование на основе DPI дисплея.
|
||||
if hasattr(Qt, "AA_EnableHighDpiScaling"):
|
||||
# Атрибуты актуальны только для Qt5: в Qt6 HighDPI включён по умолчанию и они no-op.
|
||||
if not QT6 and hasattr(Qt, "AA_EnableHighDpiScaling"):
|
||||
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
|
||||
# AA_UseHighDpiPixmaps позволяет использовать высококачественные иконки (например, @2x).
|
||||
if hasattr(Qt, "AA_UseHighDpiPixmaps"):
|
||||
if not QT6 and hasattr(Qt, "AA_UseHighDpiPixmaps"):
|
||||
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
|
||||
# QApplication должен быть создан до использования любых других частей Qt
|
||||
app = QApplication(sys.argv)
|
||||
@@ -1030,8 +1036,7 @@ def main():
|
||||
|
||||
server.newConnection.connect(handle_new_connection)
|
||||
|
||||
# Сохраняем ссылку на сервер, чтобы он не был удален сборщиком мусора
|
||||
window.server = server
|
||||
# Локальная переменная server живёт в main() всё время работы app.exec()
|
||||
window.show()
|
||||
# Создаем иконку в системном трее после создания окна
|
||||
# НЕ УДАЛЯТЬ: код трея (create_tray_icon и связанные методы) оставлен
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.parsers.sha256_parser import Sha256Parser
|
||||
@@ -16,10 +15,12 @@ from winehelperqt.qt_compat import (
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QProcess,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
Qt,
|
||||
QTabWidget,
|
||||
QTimer,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
@@ -45,18 +46,21 @@ class BaseVersionSelectionDialog(QDialog):
|
||||
|
||||
NUM_COLUMNS = 3
|
||||
|
||||
# Кэш версии системного Wine на время сессии (не меняется между открытиями диалога)
|
||||
# Кэш версии системного Wine на время сессии.
|
||||
# None — версия ещё не определена; "" — определить не удалось
|
||||
# (кэшируем и неудачу, чтобы не запускать wine --version при каждом открытии диалога);
|
||||
# непустая строка — определённая версия.
|
||||
_system_wine_version_cache = None
|
||||
|
||||
@classmethod
|
||||
def get_system_wine_version(cls):
|
||||
"""Возвращает закэшированную версию системного Wine (None, если ещё не определена)."""
|
||||
"""Возвращает версию системного Wine (None — ещё не определена)."""
|
||||
return cls._system_wine_version_cache
|
||||
|
||||
@classmethod
|
||||
def set_system_wine_version(cls, version):
|
||||
"""Запоминает версию системного Wine на время сессии."""
|
||||
cls._system_wine_version_cache = version
|
||||
"""Запоминает результат определения версии системного Wine (включая неудачу)."""
|
||||
cls._system_wine_version_cache = version or ""
|
||||
|
||||
def __init__(self, title, parent=None, min_width=600, min_height=400):
|
||||
"""
|
||||
@@ -176,30 +180,71 @@ class WineVersionSelectionDialogBase(BaseVersionSelectionDialog):
|
||||
"""
|
||||
Получает версию системного Wine (с кэшированием на время сессии).
|
||||
|
||||
Определение выполняется асинхронно через QProcess, чтобы открытие
|
||||
диалога не блокировало GUI на время работы wine --version; по готовности
|
||||
текст кнопки системной версии обновляется на вкладке «Установленные версии Wine».
|
||||
|
||||
Returns:
|
||||
Строка с версией; "Системная версия", если версию определить
|
||||
не удалось; None, если Wine не установлен в системе.
|
||||
"""
|
||||
cached = BaseVersionSelectionDialog.get_system_wine_version()
|
||||
if cached is not None:
|
||||
return cached
|
||||
return cached or "Системная версия"
|
||||
|
||||
if not shutil.which("wine"):
|
||||
logger.debug("Системный wine не найден в системе")
|
||||
return None
|
||||
|
||||
version = "Системная версия"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["wine", "--version"], capture_output=True, text=True, check=True, encoding="utf-8", timeout=5
|
||||
)
|
||||
version = result.stdout.strip()
|
||||
logger.debug("Системный wine: %s", version)
|
||||
BaseVersionSelectionDialog.set_system_wine_version(version)
|
||||
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||
logger.warning("Не удалось получить версию системного wine: %s", e)
|
||||
self._probe_system_wine_version()
|
||||
return "Системная версия"
|
||||
|
||||
return version
|
||||
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:
|
||||
if exit_code == 0:
|
||||
output = proc.readAllStandardOutput().data().decode("utf-8", errors="ignore").strip()
|
||||
version = output.splitlines()[0].strip() if output else ""
|
||||
except RuntimeError:
|
||||
pass
|
||||
# Кэшируем и неудачу (""), чтобы повторные открытия диалога
|
||||
# не запускали новые процессы wine --version
|
||||
BaseVersionSelectionDialog.set_system_wine_version(version)
|
||||
if version:
|
||||
logger.debug("Системный wine: %s", version)
|
||||
self._update_system_wine_button_text()
|
||||
else:
|
||||
logger.warning("Не удалось определить версию системного wine")
|
||||
|
||||
def on_error(_error):
|
||||
# Не перезаписываем версию, если её уже определил параллельный запрос
|
||||
if BaseVersionSelectionDialog.get_system_wine_version() is None:
|
||||
BaseVersionSelectionDialog.set_system_wine_version("")
|
||||
|
||||
proc.finished.connect(on_finished)
|
||||
proc.errorOccurred.connect(on_error)
|
||||
proc.start("wine", ["--version"])
|
||||
|
||||
def _update_system_wine_button_text(self):
|
||||
"""Обновляет текст кнопки системной версии после асинхронного определения версии."""
|
||||
version = BaseVersionSelectionDialog.get_system_wine_version()
|
||||
if not version:
|
||||
return
|
||||
for btn in getattr(self, "installed_buttons", []):
|
||||
if getattr(btn, "value_name", None) == "system":
|
||||
btn.setText(f"{version} (Системный)")
|
||||
break
|
||||
|
||||
def populate_ui(self):
|
||||
"""Заполняет UI версиями Wine."""
|
||||
|
||||
@@ -11,7 +11,6 @@ from winehelperqt.parsers.sha256_parser import Sha256Parser
|
||||
from winehelperqt.qt_compat import (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QFont,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
@@ -23,6 +22,7 @@ from winehelperqt.qt_compat import (
|
||||
QWidget,
|
||||
)
|
||||
from winehelperqt.utils.environment import EnvironmentConfig
|
||||
from winehelperqt.utils.font_utils import app_font
|
||||
|
||||
|
||||
class CreatePrefixDialog(QDialog):
|
||||
@@ -106,7 +106,7 @@ class CreatePrefixDialog(QDialog):
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
self.create_button = QPushButton("Создать")
|
||||
self.create_button.setFont(QFont("Arial", 11, QFont.Weight.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)
|
||||
@@ -122,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()
|
||||
@@ -165,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):
|
||||
"""Проверяет имя префикса в реальном времени."""
|
||||
@@ -213,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()
|
||||
|
||||
|
||||
@@ -2,18 +2,14 @@
|
||||
Диалог/виджет глобальных настроек WineHelper GUI.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from winehelperqt.logger import get_logger, setup_logger
|
||||
from winehelperqt.qt_compat import (
|
||||
CURRENT_BINDING,
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QProcess,
|
||||
QPushButton,
|
||||
@@ -97,35 +93,6 @@ class GlobalSettingsWidget(QWidget):
|
||||
|
||||
form_layout.addRow(self.qt_binding_label, self.qt_binding_combo)
|
||||
|
||||
self.work_path_label = QLabel("Рабочий каталог:")
|
||||
self.work_path_label.setToolTip(
|
||||
"Каталог, в котором хранятся префиксы, версии Wine и загруженные файлы.\n"
|
||||
"Аналог команды: winehelper data-path /путь/к/каталогу\n"
|
||||
"Данные из текущего каталога не переносятся автоматически.\n"
|
||||
"Изменение применяется после перезапуска WineHelper."
|
||||
)
|
||||
|
||||
self.work_path_edit = QLineEdit()
|
||||
self.work_path_edit.setMinimumWidth(200)
|
||||
self.work_path_edit.setPlaceholderText("/путь/к/каталогу")
|
||||
|
||||
self.work_path_browse_button = QPushButton("Обзор...")
|
||||
self.work_path_browse_button.clicked.connect(self._browse_work_path)
|
||||
self.work_path_browse_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
|
||||
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)
|
||||
|
||||
layout.addLayout(form_layout)
|
||||
|
||||
layout.addStretch()
|
||||
@@ -153,42 +120,6 @@ class GlobalSettingsWidget(QWidget):
|
||||
if index >= 0:
|
||||
self.qt_binding_combo.setCurrentIndex(index)
|
||||
|
||||
self.work_path_edit.setText(self.settings_service.get_work_path())
|
||||
|
||||
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):
|
||||
"""
|
||||
Сохраняет рабочий каталог при его изменении.
|
||||
|
||||
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():
|
||||
return True
|
||||
|
||||
confirmed = DialogUtils.confirm(
|
||||
self,
|
||||
"Изменение рабочего каталога",
|
||||
f"Рабочий каталог будет изменён на:\n{new_path}\n\n"
|
||||
"Данные из текущего каталога (префиксы, версии Wine) не переносятся автоматически.\n"
|
||||
"Изменение вступит в силу после перезапуска WineHelper.\n\nПродолжить?",
|
||||
)
|
||||
if not confirmed:
|
||||
return False
|
||||
|
||||
success, error = self.settings_service.set_work_path(new_path)
|
||||
if not success:
|
||||
QMessageBox.warning(self, "Ошибка", error)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _save_qt_binding(self):
|
||||
"""
|
||||
Сохраняет выбранный Qt binding; при смене фактического binding'а
|
||||
@@ -268,9 +199,6 @@ class GlobalSettingsWidget(QWidget):
|
||||
|
||||
def _save_settings(self):
|
||||
"""Сохраняет настройки; Qt binding — последним, т.к. может инициировать перезапуск."""
|
||||
if not self._save_work_path():
|
||||
return
|
||||
|
||||
self._save_log_level()
|
||||
|
||||
if not self._save_qt_binding():
|
||||
|
||||
@@ -120,6 +120,23 @@ class AdditionalSettingsDialog(QDialog):
|
||||
grid_layout.addWidget(self.windows_version_button, 3, 0)
|
||||
self.windows_version_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
|
||||
# Ключ защиты Guardant (grdwine)
|
||||
self.grdwine_button = QPushButton("Ключ Guardant")
|
||||
self.grdwine_button.setMinimumHeight(32)
|
||||
self.grdwine_button.setCheckable(True)
|
||||
self.grdwine_button.setToolTip(
|
||||
"Подкидывать в Wine библиотеки grdwine из системного пакета wine-grdwine (ключ защиты Guardant)."
|
||||
)
|
||||
self.grdwine_button.clicked.connect(lambda checked: self._toggle_sync_option("WH_USE_GRDWINE", checked))
|
||||
self.grdwine_button.toggled.connect(
|
||||
lambda checked, btn=self.grdwine_button: self._update_button_text(btn, "Ключ Guardant", checked)
|
||||
)
|
||||
self.grdwine_button.setStyleSheet(
|
||||
"QPushButton:checked { background-color: #4CAF50; color: white; font-weight: bold; }"
|
||||
)
|
||||
grid_layout.addWidget(self.grdwine_button, 3, 1)
|
||||
self.grdwine_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
|
||||
layout.addLayout(grid_layout)
|
||||
|
||||
self.close_button = QPushButton("Закрыть")
|
||||
@@ -167,22 +184,27 @@ class AdditionalSettingsDialog(QDialog):
|
||||
cpcsp_proxy_enabled = (
|
||||
self.parent_tab.get_prefix_component_version(self.prefix_name, "WH_USE_CPCSP_PROXY") == "1"
|
||||
)
|
||||
grdwine_enabled = self.parent_tab.get_prefix_component_version(self.prefix_name, "WH_USE_GRDWINE") == "1"
|
||||
|
||||
self.esync_button.blockSignals(True)
|
||||
self.fsync_button.blockSignals(True)
|
||||
self.cpcsp_proxy_button.blockSignals(True)
|
||||
self.grdwine_button.blockSignals(True)
|
||||
|
||||
self.esync_button.setChecked(esync_enabled)
|
||||
self.fsync_button.setChecked(fsync_enabled)
|
||||
self.cpcsp_proxy_button.setChecked(cpcsp_proxy_enabled)
|
||||
self.grdwine_button.setChecked(grdwine_enabled)
|
||||
|
||||
self._update_button_text(self.esync_button, "ESync", esync_enabled)
|
||||
self._update_button_text(self.fsync_button, "FSync", fsync_enabled)
|
||||
self._update_button_text(self.cpcsp_proxy_button, "CPCSP_PROXY", cpcsp_proxy_enabled)
|
||||
self._update_button_text(self.grdwine_button, "Ключ Guardant", grdwine_enabled)
|
||||
|
||||
self.esync_button.blockSignals(False)
|
||||
self.fsync_button.blockSignals(False)
|
||||
self.cpcsp_proxy_button.blockSignals(False)
|
||||
self.grdwine_button.blockSignals(False)
|
||||
|
||||
|
||||
class AppAdditionalSettingsDialog(QDialog):
|
||||
|
||||
@@ -294,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)
|
||||
|
||||
@@ -9,7 +9,6 @@ from winehelperqt.logger import get_logger
|
||||
from winehelperqt.parsers.winetricks_parser import WinetricksOutputParser
|
||||
from winehelperqt.qt_compat import (
|
||||
QDialog,
|
||||
QFont,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
@@ -30,6 +29,7 @@ from winehelperqt.qt_compat import (
|
||||
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__)
|
||||
@@ -107,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)
|
||||
@@ -244,7 +244,7 @@ class WinetricksManagerDialog(QDialog):
|
||||
for item_data in items:
|
||||
item = QListWidgetItem(item_data.item_text)
|
||||
item.setData(Qt.ItemDataRole.UserRole, item_data.name)
|
||||
item.setFont(QFont("DejaVu Sans Mono", 10))
|
||||
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)
|
||||
@@ -278,6 +278,7 @@ class WinetricksManagerDialog(QDialog):
|
||||
list_widget.clear()
|
||||
|
||||
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.ExitStatus.CrashExit:
|
||||
@@ -287,6 +288,7 @@ class WinetricksManagerDialog(QDialog):
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -76,6 +76,7 @@ if BINDING in ("auto", "pyqt6"):
|
||||
QColor,
|
||||
QCursor,
|
||||
QFont,
|
||||
QFontDatabase,
|
||||
QIcon,
|
||||
QPainter,
|
||||
QPalette,
|
||||
@@ -145,6 +146,7 @@ if not _loaded and BINDING in ("auto", "pyqt5"):
|
||||
QColor,
|
||||
QCursor,
|
||||
QFont,
|
||||
QFontDatabase,
|
||||
QIcon,
|
||||
QPainter,
|
||||
QPalette,
|
||||
@@ -230,6 +232,7 @@ __all__ = [
|
||||
"QEasingCurve",
|
||||
"QFileDialog",
|
||||
"QFont",
|
||||
"QFontDatabase",
|
||||
"QFormLayout",
|
||||
"QFrame",
|
||||
"QGridLayout",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"""
|
||||
|
||||
import shlex
|
||||
from functools import partial
|
||||
from typing import Dict, Optional
|
||||
|
||||
from winehelperqt.dialogs.winedebug_base import DEFAULT_WINEDEBUG
|
||||
@@ -54,7 +55,10 @@ class AppLauncher:
|
||||
гарантированно удалял C++-объект
|
||||
|
||||
Returns:
|
||||
QProcess запущенного приложения
|
||||
QProcess запущенного приложения. Ошибка старта процесса ОС
|
||||
(не найден исполняемый файл и т.п.) исключение не поднимает:
|
||||
она приходит асинхронно через сигналы errorOccurred/finished
|
||||
процесса (см. _on_start_error).
|
||||
"""
|
||||
if desktop_path in self._active_processes:
|
||||
raise RuntimeError(f"Приложение уже запущено: {desktop_path}")
|
||||
@@ -111,12 +115,17 @@ class AppLauncher:
|
||||
)
|
||||
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)
|
||||
@@ -124,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]:
|
||||
"""
|
||||
Отмечает приложение как завершённое и возвращает процесс.
|
||||
|
||||
@@ -3,15 +3,22 @@
|
||||
|
||||
Управляет модальным диалогом с логом выполнения, жизненным циклом 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 QFont, QObject, QProcess, QPushButton, QTextEdit, QVBoxLayout
|
||||
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__)
|
||||
@@ -20,6 +27,16 @@ logger = get_logger(__name__)
|
||||
process_logger = get_logger("process")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandContext:
|
||||
"""Состояние одного диалога выполнения команды: виджеты и процесс."""
|
||||
|
||||
dialog: CommandDialog
|
||||
process: QProcess
|
||||
log_output: QTextEdit
|
||||
close_button: QPushButton
|
||||
|
||||
|
||||
class CommandRunner(QObject):
|
||||
"""Создаёт и обслуживает диалоги выполнения команд winehelper."""
|
||||
|
||||
@@ -33,10 +50,9 @@ class CommandRunner(QObject):
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._log_handler = log_handler
|
||||
self.dialog = None
|
||||
self.process = None
|
||||
self.log_output = None
|
||||
self.close_button = None
|
||||
# Последний созданный контекст: нужен terminate_process() для завершения
|
||||
# активной команды при выходе из приложения
|
||||
self._last_context: Optional[CommandContext] = None
|
||||
|
||||
def create_dialog(
|
||||
self,
|
||||
@@ -49,7 +65,7 @@ class CommandRunner(QObject):
|
||||
min_height=400,
|
||||
output_handler=None,
|
||||
initial_text=None,
|
||||
):
|
||||
) -> CommandContext:
|
||||
"""
|
||||
Создает стандартный модальный диалог для выполнения команд winehelper.
|
||||
|
||||
@@ -58,14 +74,17 @@ class CommandRunner(QObject):
|
||||
command: Путь к исполняемому файлу
|
||||
args: Список аргументов команды
|
||||
env_vars: Словарь дополнительных переменных окружения (опционально)
|
||||
finished_callback: Функция обратного вызова при завершении (exit_code, exit_status)
|
||||
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)
|
||||
@@ -75,7 +94,7 @@ class CommandRunner(QObject):
|
||||
layout = QVBoxLayout()
|
||||
log_output = QTextEdit()
|
||||
log_output.setReadOnly(True)
|
||||
log_output.setFont(QFont("DejaVu Sans Mono", 10))
|
||||
log_output.setFont(fixed_font(10))
|
||||
layout.addWidget(log_output)
|
||||
|
||||
# Сбрасываем состояние общего обработчика логов "command": предыдущий
|
||||
@@ -95,16 +114,17 @@ class CommandRunner(QObject):
|
||||
if output_handler:
|
||||
process.readyReadStandardOutput.connect(output_handler)
|
||||
else:
|
||||
process.readyReadStandardOutput.connect(lambda: self.append_output(process, log_output))
|
||||
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(finished_callback)
|
||||
process.finished.connect(partial(self._run_finished_callback, finished_callback, context))
|
||||
else:
|
||||
process.finished.connect(
|
||||
lambda exit_code, exit_status: self.handle_finished(
|
||||
exit_code, exit_status, close_button, process, log_output
|
||||
)
|
||||
)
|
||||
process.finished.connect(partial(self.handle_finished, context))
|
||||
|
||||
ProcessUtils.prepare_process(process, env_vars)
|
||||
|
||||
@@ -124,12 +144,14 @@ class CommandRunner(QObject):
|
||||
process_logger.info("%s", line)
|
||||
process.start(command, args)
|
||||
|
||||
self.dialog = dialog
|
||||
self.process = process
|
||||
self.log_output = log_output
|
||||
self.close_button = close_button
|
||||
self._last_context = context
|
||||
|
||||
return dialog
|
||||
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):
|
||||
"""
|
||||
@@ -147,39 +169,32 @@ class CommandRunner(QObject):
|
||||
|
||||
def handle_finished(
|
||||
self,
|
||||
exit_code,
|
||||
context: CommandContext,
|
||||
exit_code: int,
|
||||
exit_status,
|
||||
close_button=None,
|
||||
process=None,
|
||||
log_output=None,
|
||||
success_message=None,
|
||||
on_success_callback=None,
|
||||
):
|
||||
success_message: Optional[str] = None,
|
||||
on_success_callback: Optional[Callable] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Обрабатывает завершение для диалога команды.
|
||||
|
||||
Args:
|
||||
context: Контекст завершившегося диалога (его виджеты и процесс)
|
||||
exit_code: Код завершения процесса
|
||||
exit_status: Статус завершения
|
||||
close_button: Кнопка закрытия диалога (по умолчанию close_button текущего диалога)
|
||||
process: QProcess процесса (по умолчанию process текущего диалога)
|
||||
log_output: Виджет лога (по умолчанию log_output текущего диалога)
|
||||
success_message: Сообщение об успехе (по умолчанию "Команда успешно завершена")
|
||||
on_success_callback: Callback функция, вызываемая при успехе
|
||||
"""
|
||||
if close_button is None:
|
||||
close_button = self.close_button
|
||||
if process is None:
|
||||
process = self.process
|
||||
if log_output is None:
|
||||
log_output = self.log_output
|
||||
process = context.process
|
||||
log_output = context.log_output
|
||||
close_button = context.close_button
|
||||
|
||||
# Финализируем обработчик логов
|
||||
if log_output:
|
||||
if not DialogUtils.is_widget_deleted(log_output):
|
||||
handler = self._log_handler.get_handler("command", log_output)
|
||||
handler.finalize()
|
||||
|
||||
if log_output and not DialogUtils.is_widget_deleted(log_output):
|
||||
if not DialogUtils.is_widget_deleted(log_output):
|
||||
try:
|
||||
if exit_code == 0:
|
||||
message = success_message if success_message else "\n=== Команда успешно завершена ==="
|
||||
@@ -189,7 +204,7 @@ class CommandRunner(QObject):
|
||||
process_logger.info("%s", line)
|
||||
if on_success_callback:
|
||||
on_success_callback()
|
||||
elif self.dialog is not None and getattr(self.dialog, "aborted_by_user", False):
|
||||
elif getattr(context.dialog, "aborted_by_user", False):
|
||||
# Прерывание по решению пользователя — не ошибка
|
||||
message = "\n=== Выполнение прервано пользователем ==="
|
||||
log_output.append(message)
|
||||
@@ -204,15 +219,13 @@ class CommandRunner(QObject):
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
if process and not DialogUtils.is_widget_deleted(process):
|
||||
if not DialogUtils.is_widget_deleted(process):
|
||||
try:
|
||||
process.deleteLater()
|
||||
except RuntimeError:
|
||||
pass
|
||||
if self.process == process:
|
||||
self.process = None
|
||||
|
||||
if close_button and not DialogUtils.is_widget_deleted(close_button):
|
||||
if not DialogUtils.is_widget_deleted(close_button):
|
||||
try:
|
||||
close_button.setEnabled(True)
|
||||
except RuntimeError:
|
||||
@@ -220,5 +233,6 @@ class CommandRunner(QObject):
|
||||
|
||||
def terminate_process(self):
|
||||
"""Завершает активный процесс команды, если он запущен (без блокировки UI)."""
|
||||
if self.process:
|
||||
ProcessUtils.stop_process(self.process)
|
||||
context = self._last_context
|
||||
if context and not DialogUtils.is_widget_deleted(context.process):
|
||||
ProcessUtils.stop_process(context.process)
|
||||
@@ -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__)
|
||||
|
||||
@@ -137,69 +136,3 @@ class GlobalSettingsService:
|
||||
self._save_config(config)
|
||||
logger.debug("Qt binding сохранён: %s", binding)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def is_work_path_changeable():
|
||||
"""
|
||||
Проверяет, можно ли изменить рабочий каталог.
|
||||
|
||||
Рабочий каталог меняется только для установленного в систему
|
||||
WineHelper (CLI экспортирует WH_CONFIG); в режиме разработки
|
||||
путь фиксирован скриптом winehelper.
|
||||
|
||||
Returns:
|
||||
bool: True если изменение поддерживается.
|
||||
"""
|
||||
return bool(os.environ.get("WH_CONFIG"))
|
||||
|
||||
def get_work_path(self):
|
||||
"""
|
||||
Возвращает текущий рабочий каталог WineHelper.
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
path: новый рабочий каталог.
|
||||
|
||||
Returns:
|
||||
tuple: (bool, str) — успех и сообщение об ошибке (пустая строка при успехе).
|
||||
"""
|
||||
path = str(path).strip()
|
||||
if not path:
|
||||
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, ""
|
||||
@@ -11,7 +11,6 @@ import shlex
|
||||
from winehelperqt.dialogs.install_dialog import InstallDialog
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.qt_compat import (
|
||||
QFont,
|
||||
QHBoxLayout,
|
||||
QMessageBox,
|
||||
QObject,
|
||||
@@ -25,6 +24,7 @@ from winehelperqt.qt_compat import (
|
||||
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
|
||||
|
||||
@@ -52,6 +52,7 @@ class InstallRunner(QObject):
|
||||
self.process = None
|
||||
self.title_name = ""
|
||||
self.cancelled = False
|
||||
self._start_failed = False
|
||||
self._btn_abort = None
|
||||
self._btn_close = None
|
||||
|
||||
@@ -59,6 +60,7 @@ class InstallRunner(QObject):
|
||||
"""Создает и показывает диалог установки с логом и кнопками управления."""
|
||||
self.title_name = title_name
|
||||
self.cancelled = False
|
||||
self._start_failed = False
|
||||
|
||||
# Создаем диалоговое окно установки
|
||||
self.dialog = InstallDialog(self.parent())
|
||||
@@ -71,7 +73,7 @@ class InstallRunner(QObject):
|
||||
|
||||
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))
|
||||
log_layout.addWidget(self.log_output)
|
||||
|
||||
control_buttons = QWidget()
|
||||
@@ -124,15 +126,23 @@ class InstallRunner(QObject):
|
||||
" ".join(shlex.quote(a) for a in args),
|
||||
)
|
||||
|
||||
try:
|
||||
self.process.start(winehelper_path, args)
|
||||
if not self.process.waitForStarted(3000):
|
||||
raise RuntimeError("Не удалось запустить процесс установки")
|
||||
self.append_log("Процесс установки запущен...")
|
||||
except Exception as e:
|
||||
self.append_log(f"\n=== ОШИБКА: {str(e)} ===", is_error=True)
|
||||
QMessageBox.critical(self.dialog, "Ошибка", f"Не удалось запустить установку:\n{str(e)}")
|
||||
self.cleanup_process()
|
||||
# Ошибку старта ждём асинхронно через 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):
|
||||
"""Прерывает текущую установку."""
|
||||
@@ -185,6 +195,9 @@ class InstallRunner(QObject):
|
||||
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:
|
||||
|
||||
@@ -76,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)
|
||||
|
||||
@@ -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 winehelperqt.qt_compat import QAbstractButton, QFrame, QGridLayout, QLabel, 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:
|
||||
@@ -17,6 +39,21 @@ class GridUtils:
|
||||
|
||||
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:
|
||||
"""Показывает/скрывает метку пустого состояния сетки.
|
||||
@@ -70,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
|
||||
|
||||
@@ -117,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
|
||||
@@ -37,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 "Не задана"),
|
||||
}
|
||||
@@ -52,6 +53,7 @@ DISPLAY_ORDER = [
|
||||
"WINEESYNC",
|
||||
"WINEFSYNC",
|
||||
"WH_USE_CPCSP_PROXY",
|
||||
"WH_USE_GRDWINE",
|
||||
"WH_WINDOWS_VER",
|
||||
"WH_XDG_OPEN",
|
||||
]
|
||||
|
||||
@@ -9,8 +9,9 @@ import os
|
||||
import re
|
||||
|
||||
from winehelperqt.logger import get_logger
|
||||
from winehelperqt.qt_compat import QFont, QTabWidget, QTextBrowser, QTextEdit, QVBoxLayout, QWidget
|
||||
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__)
|
||||
|
||||
@@ -182,7 +183,7 @@ class HelpTab(QWidget):
|
||||
changelog_layout = QVBoxLayout(changelog_tab)
|
||||
changelog_text = QTextEdit()
|
||||
changelog_text.setReadOnly(True)
|
||||
changelog_text.setFont(QFont("DejaVu Sans Mono", 9))
|
||||
changelog_text.setFont(fixed_font(9))
|
||||
|
||||
try:
|
||||
if not EnvironmentConfig.CHANGELOG_FILE or not os.path.exists(EnvironmentConfig.CHANGELOG_FILE):
|
||||
|
||||
@@ -221,6 +221,31 @@ class IconManager(QObject):
|
||||
if "animation" in anim_data and anim_data["animation"]:
|
||||
anim_data["animation"].stop()
|
||||
|
||||
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:
|
||||
"""
|
||||
Запускает анимацию плавного перехода между иконками.
|
||||
|
||||
@@ -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)
|
||||
@@ -28,6 +28,7 @@ from winehelperqt.qt_compat import (
|
||||
)
|
||||
from winehelperqt.utils.environment import EnvironmentConfig
|
||||
from winehelperqt.utils.grid_utils import GridUtils
|
||||
from winehelperqt.widgets.tab_names import TabNames
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -60,19 +61,17 @@ class InstallTab(QWidget):
|
||||
self.test_checkbox = None # Чекбокс «Показать тестовые версии» (только для 'auto')
|
||||
|
||||
# Содержимое вкладки: поиск + сетка кнопок (общий помощник главного окна)
|
||||
(
|
||||
tab_content,
|
||||
self.grid_layout,
|
||||
self.search_edit,
|
||||
self.scroll_area,
|
||||
self.search_desc_checkbox,
|
||||
self.empty_label,
|
||||
) = gui.create_searchable_grid_tab(search_placeholder, self.filter_buttons, with_description_search=True)
|
||||
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(tab_content)
|
||||
layout.addWidget(content.widget)
|
||||
|
||||
self._populate_scripts(script_folders)
|
||||
|
||||
@@ -86,6 +85,24 @@ class InstallTab(QWidget):
|
||||
# Начальное состояние метки пустого состояния (на случай пустого списка скриптов)
|
||||
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:
|
||||
@@ -166,6 +183,9 @@ class InstallTab(QWidget):
|
||||
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():
|
||||
@@ -190,14 +210,14 @@ class InstallTab(QWidget):
|
||||
# Для заголовка используем первую иконку из списка
|
||||
icon_path = os.path.join(EnvironmentConfig.DATA_PATH, "image", f"{script_info.icons[0]}.png")
|
||||
if os.path.exists(icon_path):
|
||||
gui.script_title.setPixmap(QPixmap(icon_path).scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio))
|
||||
gui.info_panel.set_title_icon(QPixmap(icon_path).scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio))
|
||||
else:
|
||||
gui.script_title.setPixmap(QPixmap())
|
||||
gui.info_panel.set_title_icon(QPixmap())
|
||||
else:
|
||||
gui.script_title.setPixmap(QPixmap())
|
||||
gui.info_panel.set_title_icon(QPixmap())
|
||||
|
||||
gui.script_title.setText(display_name)
|
||||
gui.script_title.setVisible(True)
|
||||
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>"
|
||||
@@ -205,8 +225,8 @@ class InstallTab(QWidget):
|
||||
escaped_url = html.escape(script_info.url)
|
||||
html_description += f'<p><b>Официальный сайт:</b> <a href="{escaped_url}">{escaped_url}</a></p>'
|
||||
|
||||
gui.script_description.setHtml(html_description)
|
||||
gui.script_description.setVisible(True)
|
||||
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)
|
||||
@@ -262,7 +282,7 @@ class InstallTab(QWidget):
|
||||
"""Удаляет тестовые скрипты из UI."""
|
||||
# Если текущая активная кнопка находится среди удаляемых, сбрасываем ее
|
||||
if self.gui.selected_app_button in self.test_buttons:
|
||||
self.gui.reset_info_panel_to_default("Автоматическая установка")
|
||||
self.gui.reset_info_panel_to_default(TabNames.AUTO_INSTALL)
|
||||
self.gui.selected_app_button = None
|
||||
|
||||
# Останавливаем анимацию и удаляем виджеты тестовых кнопок
|
||||
@@ -380,7 +400,7 @@ class ExternalInstallTab(QWidget):
|
||||
QMessageBox.warning(self, "Ошибка", f"Установочный файл не найден:\n{install_file}")
|
||||
return
|
||||
|
||||
if not self.gui.show_license_agreement_dialog():
|
||||
if not self.gui.ensure_license_accepted():
|
||||
return # Пользователь отклонил лицензию
|
||||
|
||||
# Имя для заголовка диалога: из скрипта, иначе имя файла
|
||||
|
||||
@@ -39,6 +39,7 @@ 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__)
|
||||
|
||||
@@ -70,28 +71,45 @@ class InstalledTab(QWidget):
|
||||
self.installed_app_buttons = [] # Список кнопок установленных приложений
|
||||
self._is_stopping = False # Флаг ожидания остановки приложения
|
||||
self._debug_launch_active = False # Флаг запуска с созданием лога
|
||||
self._installed_apps_signature = None # Кэш сигнатуры сетки установленных (задача кэша)
|
||||
|
||||
# Содержимое вкладки: поиск + сетка кнопок (общий помощник главного окна)
|
||||
(
|
||||
tab_content,
|
||||
self.installed_scroll_layout,
|
||||
self.installed_search_edit,
|
||||
self.installed_scroll_area,
|
||||
_,
|
||||
self.empty_label,
|
||||
) = gui.create_searchable_grid_tab(
|
||||
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(tab_content)
|
||||
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):
|
||||
"""Создаёт секцию с кнопками действий для установленных приложений."""
|
||||
# Виджет для действий с установленным приложением
|
||||
@@ -126,7 +144,7 @@ class InstalledTab(QWidget):
|
||||
installed_action_layout.addWidget(self.log_progress_bar)
|
||||
|
||||
self.installed_action_widget.setLayout(installed_action_layout)
|
||||
self.gui.info_panel_layout.addWidget(self.installed_action_widget)
|
||||
self.gui.info_panel.add_section(self.installed_action_widget)
|
||||
|
||||
# Фиксированный промежуток между кнопкой "Запустить" и остальными кнопками.
|
||||
# Компенсирует подъём кнопки "Запустить", чтобы остальные кнопки
|
||||
@@ -135,7 +153,7 @@ class InstalledTab(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_layout.addWidget(self.installed_buttons_spacer)
|
||||
self.gui.info_panel.add_section(self.installed_buttons_spacer)
|
||||
|
||||
# Глобальные действия для установленных приложения
|
||||
self.installed_global_action_widget = QWidget()
|
||||
@@ -175,7 +193,7 @@ class InstalledTab(QWidget):
|
||||
installed_global_layout.addWidget(self.restore_prefix_button_panel)
|
||||
|
||||
self.installed_global_action_widget.setLayout(installed_global_layout)
|
||||
self.gui.info_panel_layout.addWidget(self.installed_global_action_widget)
|
||||
self.gui.info_panel.add_section(self.installed_global_action_widget)
|
||||
|
||||
def show_default_panel(self, show_global_actions=False):
|
||||
"""Сбрасывает секцию действий установленного приложения в состояние по умолчанию."""
|
||||
@@ -203,10 +221,35 @@ class InstalledTab(QWidget):
|
||||
|
||||
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)
|
||||
@@ -215,9 +258,6 @@ class InstalledTab(QWidget):
|
||||
widget.deleteLater()
|
||||
self.installed_app_buttons.clear()
|
||||
|
||||
# Получаем информацию о приложениях из сервиса
|
||||
apps = self.desktop_file_manager.get_installed_apps_with_info()
|
||||
|
||||
for i, app_info in enumerate(apps):
|
||||
desktop_path = app_info["path"]
|
||||
display_name = app_info["display_name"]
|
||||
@@ -274,13 +314,13 @@ class InstalledTab(QWidget):
|
||||
self._set_run_button_state(is_app_running)
|
||||
|
||||
# Показываем панель информации
|
||||
self.gui.info_panel.setVisible(True)
|
||||
self.gui.info_panel.set_visible(True)
|
||||
|
||||
# Показываем информацию в правой панели
|
||||
self.gui.script_title.setPixmap(QPixmap())
|
||||
self.gui.script_title.setText(app_info.get("Name", ""))
|
||||
self.gui.script_title.setVisible(True)
|
||||
self.gui.script_description.setVisible(False)
|
||||
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)
|
||||
@@ -358,27 +398,18 @@ class InstalledTab(QWidget):
|
||||
):
|
||||
return
|
||||
|
||||
dialog = self.command_runner.create_dialog(
|
||||
f"Резервное копирование: {prefix_name}", self.winehelper_path, ["backup-prefix", prefix_name]
|
||||
)
|
||||
|
||||
# Создаем callback после сохранения ссылок
|
||||
def on_finished(exit_code, exit_status):
|
||||
self.command_runner.handle_finished(
|
||||
exit_code,
|
||||
exit_status,
|
||||
self.command_runner.close_button,
|
||||
self.command_runner.process,
|
||||
self.command_runner.log_output,
|
||||
)
|
||||
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()
|
||||
|
||||
# Заменяем стандартный обработчик на наш
|
||||
self.command_runner.process.finished.disconnect()
|
||||
self.command_runner.process.finished.connect(on_finished)
|
||||
|
||||
dialog.exec()
|
||||
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):
|
||||
"""Восстанавливает префикс из резервной копии."""
|
||||
@@ -402,16 +433,16 @@ class InstalledTab(QWidget):
|
||||
QMessageBox.critical(self, "Ошибка", f"Файл резервной копии не найден:\n{backup_path}")
|
||||
return
|
||||
|
||||
def on_finished(exit_code, exit_status):
|
||||
self.gui.handle_restore_finished(exit_code, exit_status)
|
||||
def on_finished(context, exit_code, exit_status):
|
||||
self.gui.handle_restore_finished(context, exit_code, exit_status)
|
||||
|
||||
dialog = self.command_runner.create_dialog(
|
||||
context = self.command_runner.create_dialog(
|
||||
f"Восстановление из: {os.path.basename(backup_path)}",
|
||||
self.winehelper_path,
|
||||
["restore-prefix", backup_path],
|
||||
finished_callback=on_finished,
|
||||
)
|
||||
dialog.exec()
|
||||
context.dialog.exec()
|
||||
|
||||
def run_installed_app_with_debug(self):
|
||||
"""Запускает выбранное установленное приложение с созданием лога"""
|
||||
@@ -581,7 +612,9 @@ class InstalledTab(QWidget):
|
||||
# Сбрасываем зелёную рамку на кнопке в сетке
|
||||
self._set_app_running_indicator(desktop_path, False)
|
||||
else:
|
||||
logger.warning("Получен сигнал finished для неизвестного процесса %s", desktop_path)
|
||||
# Процесс уже убран из реестра: например, неудачный старт
|
||||
# очищает реестр в _on_start_error до сигнала finished
|
||||
logger.debug("Получен сигнал finished для неизвестного процесса %s", desktop_path)
|
||||
|
||||
def _set_app_running_indicator(self, desktop_path, running):
|
||||
"""Помечает кнопку приложения в сетке 'Установленные' зелёной рамкой (запущено) или сбрасывает её."""
|
||||
@@ -741,24 +774,21 @@ class InstalledTab(QWidget):
|
||||
):
|
||||
return
|
||||
|
||||
def on_finished(exit_code, exit_status):
|
||||
self._handle_app_uninstall_finished(prefix_name, exit_code, exit_status)
|
||||
|
||||
dialog = self.command_runner.create_dialog(
|
||||
context = self.command_runner.create_dialog(
|
||||
f"Удаление приложения: {app_name}",
|
||||
self.winehelper_path,
|
||||
["remove-prefix", prefix_name, "--force"],
|
||||
finished_callback=on_finished,
|
||||
finished_callback=partial(self._handle_app_uninstall_finished, prefix_name),
|
||||
)
|
||||
dialog.exec()
|
||||
context.dialog.exec()
|
||||
|
||||
def _handle_app_uninstall_finished(self, prefix_name, exit_code, exit_status):
|
||||
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("Установленные")
|
||||
self.gui.reset_info_panel_to_default(TabNames.INSTALLED)
|
||||
|
||||
def on_error():
|
||||
QMessageBox.critical(
|
||||
@@ -768,7 +798,7 @@ class InstalledTab(QWidget):
|
||||
)
|
||||
|
||||
self.command_runner.handle_finished(
|
||||
exit_code, exit_status, on_success_callback=on_success if exit_code == 0 else None
|
||||
context, exit_code, exit_status, on_success_callback=on_success if exit_code == 0 else None
|
||||
)
|
||||
if exit_code != 0:
|
||||
on_error()
|
||||
|
||||
@@ -9,6 +9,7 @@ import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
from functools import partial
|
||||
|
||||
from winehelperqt.dialogs.component_version import ComponentVersionSelectionDialog
|
||||
from winehelperqt.dialogs.create_prefix import CreatePrefixDialog
|
||||
@@ -29,7 +30,6 @@ from winehelperqt.qt_compat import (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QFileDialog,
|
||||
QFont,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
@@ -47,6 +47,7 @@ from winehelperqt.qt_compat import (
|
||||
)
|
||||
from winehelperqt.utils.dialog_utils import DialogUtils
|
||||
from winehelperqt.utils.environment import EnvironmentConfig
|
||||
from winehelperqt.utils.font_utils import app_font
|
||||
from winehelperqt.utils.prefix_info import build_prefix_info_html
|
||||
from winehelperqt.utils.process_utils import ProcessUtils
|
||||
|
||||
@@ -110,7 +111,7 @@ class PrefixTab(QWidget):
|
||||
creation_groupbox.setFixedHeight(60)
|
||||
creation_layout = QVBoxLayout(creation_groupbox)
|
||||
create_prefix_button = QPushButton("Создать новый префикс")
|
||||
create_prefix_button.setFont(QFont("Arial", 11, QFont.Weight.Bold))
|
||||
create_prefix_button.setFont(app_font(11, bold=True))
|
||||
create_prefix_button.setMinimumHeight(32)
|
||||
create_prefix_button.setStyleSheet("background-color: #0078d7; color: white; padding: 5px;")
|
||||
create_prefix_button.clicked.connect(self.open_create_prefix_dialog)
|
||||
@@ -339,7 +340,7 @@ class PrefixTab(QWidget):
|
||||
}
|
||||
""")
|
||||
self.remove_all_button.setMinimumHeight(32)
|
||||
self.remove_all_button.setFont(QFont("Arial", 11, QFont.Weight.Bold))
|
||||
self.remove_all_button.setFont(app_font(11, bold=True))
|
||||
self.remove_all_button.setToolTip(
|
||||
"ВНИМАНИЕ: Удаляет ВСЕ данные WineHelper, включая все префиксы, настройки и ярлыки."
|
||||
)
|
||||
@@ -361,12 +362,12 @@ class PrefixTab(QWidget):
|
||||
):
|
||||
return
|
||||
|
||||
dialog = self.command_runner.create_dialog(
|
||||
context = self.command_runner.create_dialog(
|
||||
"Остановка всех процессов Wine",
|
||||
self.winehelper_path,
|
||||
["killall"],
|
||||
)
|
||||
dialog.exec()
|
||||
context.dialog.exec()
|
||||
|
||||
def get_current_prefixes(self):
|
||||
"""Возвращает множество имен существующих префиксов."""
|
||||
@@ -452,18 +453,15 @@ class PrefixTab(QWidget):
|
||||
):
|
||||
return
|
||||
|
||||
def on_finished(exit_code, exit_status):
|
||||
self._handle_prefix_deletion_finished(prefix_name, exit_code, exit_status)
|
||||
|
||||
dialog = self.command_runner.create_dialog(
|
||||
context = self.command_runner.create_dialog(
|
||||
f"Удаление префикса: {prefix_name}",
|
||||
self.winehelper_path,
|
||||
["remove-prefix", prefix_name, "--force"],
|
||||
finished_callback=on_finished,
|
||||
finished_callback=partial(self._handle_prefix_deletion_finished, prefix_name),
|
||||
)
|
||||
dialog.exec()
|
||||
context.dialog.exec()
|
||||
|
||||
def _handle_prefix_deletion_finished(self, prefix_name, exit_code, exit_status):
|
||||
def _handle_prefix_deletion_finished(self, prefix_name, context, exit_code, exit_status):
|
||||
"""Обрабатывает завершение процесса удаления префикса."""
|
||||
|
||||
def on_success():
|
||||
@@ -476,7 +474,7 @@ class PrefixTab(QWidget):
|
||||
)
|
||||
|
||||
self.command_runner.handle_finished(
|
||||
exit_code, exit_status, on_success_callback=on_success if exit_code == 0 else None
|
||||
context, exit_code, exit_status, on_success_callback=on_success if exit_code == 0 else None
|
||||
)
|
||||
if exit_code != 0:
|
||||
on_error()
|
||||
@@ -498,10 +496,10 @@ class PrefixTab(QWidget):
|
||||
):
|
||||
return
|
||||
|
||||
dialog = self.command_runner.create_dialog(
|
||||
context = self.command_runner.create_dialog(
|
||||
f"Создание шаблона: {prefix_name}", self.winehelper_path, ["create-base-pfx", prefix_name]
|
||||
)
|
||||
dialog.exec()
|
||||
context.dialog.exec()
|
||||
|
||||
def open_selected_prefix_folder(self):
|
||||
"""Открывает папку выбранного префикса в системном файловом менеджере."""
|
||||
@@ -604,22 +602,23 @@ class PrefixTab(QWidget):
|
||||
QMessageBox.warning(self, "Ошибка", "Указан неверный путь к установочному файлу.")
|
||||
return
|
||||
|
||||
dialog = self.command_runner.create_dialog(
|
||||
context = self.command_runner.create_dialog(
|
||||
f"Запуск в префиксе: {prefix_name}",
|
||||
self.winehelper_path,
|
||||
["run-in-prefix", prefix_name, installer_path],
|
||||
env_vars=self._get_prefix_tab_log_env(),
|
||||
finished_callback=self._handle_prefix_install_finished,
|
||||
)
|
||||
dialog.exec()
|
||||
context.dialog.exec()
|
||||
|
||||
def _handle_prefix_install_finished(self, exit_code, exit_status):
|
||||
def _handle_prefix_install_finished(self, context, exit_code, exit_status):
|
||||
"""Обрабатывает завершение установки в префикс."""
|
||||
|
||||
def on_success():
|
||||
self.create_launcher_button.setEnabled(True)
|
||||
|
||||
self.command_runner.handle_finished(
|
||||
context,
|
||||
exit_code,
|
||||
exit_status,
|
||||
success_message="\n=== Работа Wine завершена ===" if exit_code == 0 else None,
|
||||
@@ -735,20 +734,18 @@ class PrefixTab(QWidget):
|
||||
"""
|
||||
prefix_path = self.prefix_manager.get_prefix_path(prefix_name)
|
||||
|
||||
dialog = self.command_runner.create_dialog(
|
||||
context = self.command_runner.create_dialog(
|
||||
title,
|
||||
self.winehelper_path,
|
||||
args,
|
||||
env_vars={"WINEPREFIX": prefix_path},
|
||||
finished_callback=lambda exit_code, exit_status: self._handle_prefix_command_finished(
|
||||
prefix_name, exit_code, exit_status
|
||||
),
|
||||
finished_callback=partial(self._handle_prefix_command_finished, prefix_name),
|
||||
)
|
||||
dialog.exec()
|
||||
context.dialog.exec()
|
||||
|
||||
def _handle_prefix_command_finished(self, prefix_name, exit_code, exit_status):
|
||||
def _handle_prefix_command_finished(self, prefix_name, context, exit_code, exit_status):
|
||||
"""Обрабатывает завершение команды; при успехе обновляет инфо-панель префикса."""
|
||||
self.command_runner.handle_finished(exit_code, exit_status)
|
||||
self.command_runner.handle_finished(context, exit_code, exit_status)
|
||||
if exit_code == 0:
|
||||
self.update_prefix_info_display(prefix_name)
|
||||
|
||||
@@ -853,14 +850,14 @@ class PrefixTab(QWidget):
|
||||
return
|
||||
|
||||
# 3. Вызываем winehelper create-desktop
|
||||
dialog = self.command_runner.create_dialog(
|
||||
context = self.command_runner.create_dialog(
|
||||
f"Создание ярлыка для: {app_name}",
|
||||
self.winehelper_path,
|
||||
["desktop", app_name, exe_path, "auto"],
|
||||
env_vars={"WINEPREFIX": prefix_path},
|
||||
finished_callback=self.gui.handle_launcher_creation_finished,
|
||||
)
|
||||
dialog.exec()
|
||||
context.dialog.exec()
|
||||
|
||||
def open_create_prefix_dialog(self):
|
||||
"""Открывает диалог создания нового префикса."""
|
||||
@@ -907,21 +904,19 @@ class PrefixTab(QWidget):
|
||||
f"Тип: {prefix_type}\n\n" + "=" * 40 + "\n"
|
||||
)
|
||||
|
||||
dialog = self.command_runner.create_dialog(
|
||||
context = self.command_runner.create_dialog(
|
||||
f"Создание префикса: {prefix_name}",
|
||||
self.winehelper_path,
|
||||
["init-prefix"],
|
||||
env_vars=env_vars,
|
||||
finished_callback=self._handle_prefix_creation_finished,
|
||||
finished_callback=partial(self._handle_prefix_creation_finished, prefix_name),
|
||||
initial_text=params_text,
|
||||
)
|
||||
dialog.exec()
|
||||
context.dialog.exec()
|
||||
|
||||
def _handle_prefix_creation_finished(self, exit_code, exit_status):
|
||||
def _handle_prefix_creation_finished(self, prefix_name, context, exit_code, exit_status):
|
||||
"""Обрабатывает завершение создания префикса."""
|
||||
prefix_name = self.command_runner.process.processEnvironment().value("WINEPREFIX").split("/")[-1]
|
||||
|
||||
self.command_runner.handle_finished(exit_code, exit_status)
|
||||
self.command_runner.handle_finished(context, exit_code, exit_status)
|
||||
if exit_code == 0:
|
||||
# Добавляем новый префикс в список и выбираем его
|
||||
if self.created_prefix_selector.findText(prefix_name) == -1:
|
||||
@@ -1006,18 +1001,21 @@ class PrefixTab(QWidget):
|
||||
result_callback(False)
|
||||
return
|
||||
|
||||
def on_finished(exit_code, exit_status):
|
||||
def on_finished(context, exit_code, exit_status):
|
||||
success = exit_code == 0
|
||||
log_output = context.log_output
|
||||
# Сбрасываем незавершённый хвост вывода процесса в виджет
|
||||
self.gui.log_handler.get_handler("command", log_output).finalize()
|
||||
if success:
|
||||
self.update_prefix_info_display(prefix_name)
|
||||
self.command_runner.log_output.append("")
|
||||
self.command_runner.log_output.append(
|
||||
log_output.append("")
|
||||
log_output.append(
|
||||
'<span style="color: #4CAF50; font-weight: bold;">✓ Установка CPCSP_PROXY успешно завершена</span>'
|
||||
)
|
||||
process_logger.info("Установка CPCSP_PROXY успешно завершена")
|
||||
else:
|
||||
self.command_runner.log_output.append("")
|
||||
self.command_runner.log_output.append(
|
||||
log_output.append("")
|
||||
log_output.append(
|
||||
'<span style="color: #d32f2f; font-weight: bold;">'
|
||||
"✗ Установка CPCSP_PROXY завершена с ошибкой</span>"
|
||||
)
|
||||
@@ -1025,19 +1023,21 @@ class PrefixTab(QWidget):
|
||||
# Откатываем last.conf, так как winehelper уже записал WH_USE_CPCSP_PROXY=1
|
||||
# до начала фактической установки
|
||||
self.settings_manager.save_prefix_settings(prefix_name, {"WH_USE_CPCSP_PROXY": "0"})
|
||||
# append() сам не прокручивает вид — докручиваем до финального сообщения
|
||||
log_output.ensureCursorVisible()
|
||||
if result_callback:
|
||||
result_callback(success)
|
||||
# Включаем кнопку закрытия в любом случае
|
||||
self.command_runner.close_button.setEnabled(True)
|
||||
context.close_button.setEnabled(True)
|
||||
|
||||
dialog = self.command_runner.create_dialog(
|
||||
context = self.command_runner.create_dialog(
|
||||
"Установка CPCSP_PROXY",
|
||||
self.winehelper_path,
|
||||
["cpcsp-proxy", "enable"],
|
||||
env_vars={"WINEPREFIX": prefix_path},
|
||||
finished_callback=on_finished,
|
||||
)
|
||||
dialog.exec()
|
||||
context.dialog.exec()
|
||||
|
||||
def run_cpcsp_proxy_disable(self, prefix_name):
|
||||
"""Отключает CPCSP_PROXY в префиксе через winehelper cpcsp-proxy disable.
|
||||
@@ -1051,30 +1051,35 @@ class PrefixTab(QWidget):
|
||||
QMessageBox.critical(self, "Ошибка", f"Каталог префикса не найден:\n{prefix_path}")
|
||||
return
|
||||
|
||||
def on_finished(exit_code, exit_status):
|
||||
def on_finished(context, exit_code, exit_status):
|
||||
log_output = context.log_output
|
||||
# Сбрасываем незавершённый хвост вывода процесса в виджет
|
||||
self.gui.log_handler.get_handler("command", log_output).finalize()
|
||||
if exit_code == 0:
|
||||
self.update_prefix_info_display(prefix_name)
|
||||
self.command_runner.log_output.append("")
|
||||
self.command_runner.log_output.append(
|
||||
log_output.append("")
|
||||
log_output.append(
|
||||
'<span style="color: #4CAF50; font-weight: bold;">✓ CPCSP_PROXY успешно выключен</span>'
|
||||
)
|
||||
process_logger.info("CPCSP_PROXY успешно выключен")
|
||||
else:
|
||||
self.command_runner.log_output.append("")
|
||||
self.command_runner.log_output.append(
|
||||
log_output.append("")
|
||||
log_output.append(
|
||||
'<span style="color: #d32f2f; font-weight: bold;">✗ Ошибка при выключении CPCSP_PROXY</span>'
|
||||
)
|
||||
process_logger.error("Ошибка при выключении CPCSP_PROXY")
|
||||
self.command_runner.close_button.setEnabled(True)
|
||||
# append() сам не прокручивает вид — докручиваем до финального сообщения
|
||||
log_output.ensureCursorVisible()
|
||||
context.close_button.setEnabled(True)
|
||||
|
||||
dialog = self.command_runner.create_dialog(
|
||||
context = self.command_runner.create_dialog(
|
||||
"Отключение CPCSP_PROXY",
|
||||
self.winehelper_path,
|
||||
["cpcsp-proxy", "disable"],
|
||||
env_vars={"WINEPREFIX": prefix_path},
|
||||
finished_callback=on_finished,
|
||||
)
|
||||
dialog.exec()
|
||||
context.dialog.exec()
|
||||
|
||||
def _run_wine_util(self, util_name, prefix_name=None):
|
||||
"""Запускает стандартную утилиту Wine для префикса через winehelper (CLI)."""
|
||||
@@ -1116,7 +1121,16 @@ class PrefixTab(QWidget):
|
||||
ProcessUtils.prepare_process(process, env_vars)
|
||||
process.readyReadStandardOutput.connect(lambda: self._forward_process_output(process))
|
||||
process.finished.connect(lambda: self._cleanup_wine_util_process(process))
|
||||
process.errorOccurred.connect(lambda: self._cleanup_wine_util_process(process))
|
||||
|
||||
def on_wine_util_error(error):
|
||||
# Ошибку старта ждём асинхронно, не блокируя GUI waitForStarted
|
||||
if error == QProcess.ProcessError.FailedToStart:
|
||||
QMessageBox.critical(
|
||||
self, "Ошибка запуска", f"Не удалось запустить утилиту {util_name} через winehelper."
|
||||
)
|
||||
self._cleanup_wine_util_process(process)
|
||||
|
||||
process.errorOccurred.connect(on_wine_util_error)
|
||||
|
||||
self._wine_util_processes.append(process)
|
||||
|
||||
@@ -1124,10 +1138,6 @@ class PrefixTab(QWidget):
|
||||
logger.debug("Запуск Wine-утилиты: %s %s", shlex.quote(self.winehelper_path), " ".join(args))
|
||||
process.start(self.winehelper_path, args)
|
||||
|
||||
if not process.waitForStarted(5000):
|
||||
QMessageBox.critical(self, "Ошибка запуска", f"Не удалось запустить утилиту {util_name} через winehelper.")
|
||||
self._cleanup_wine_util_process(process)
|
||||
|
||||
@staticmethod
|
||||
def _forward_process_output(process):
|
||||
"""Перенаправляет вывод дочернего процесса в stdout GUI (терминал)."""
|
||||
|
||||
@@ -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