Compare commits

...
14 Commits
Author SHA1 Message Date
Mikhail Tergoev 266302d643 updated adem pfx with jet40 2026-10-28 08:22:17 +03:00
Mikhail Tergoev 38d3a43c03 fix: dont mkdir prefix if break backup 2026-10-28 08:09:57 +03:00
Mikhail Tergoev c2c5a05013 docs: update AGENTS.md 2026-10-20 16:59:47 +03:00
Mikhail Tergoev 410b24c9b5 Merge branch 'minergenon-link' 2026-10-20 16:49:30 +03:00
Sergey Palcheh a525b9f749 docs: replace CLAUDE.md with symlink to AGENTS.md 2026-10-19 12:36:04 +06:00
Mikhail Tergoev 3c856cd890 added wine-11.19-alt-tkg-wow64 2026-10-16 17:13:19 +03:00
Mikhail Tergoev c2bf75d0da Merge branch 'minergenon-fix/gui-critical-batch1' 2026-10-16 15:20:42 +03:00
Sergey Palcheh 50a2498abb fix(gui): scroll CPCSP_PROXY dialog log to the end on finish 2026-10-16 10:57:57 +06:00
Sergey Palcheh 53ec0f88bd refactor(gui): remove work path setting from settings tab 2026-10-16 10:33:50 +06:00
Sergey Palcheh b8cebe18b7 gui: use system fonts, fix Qt6 deprecations and revive winetricks error branch 2026-10-15 20:44:05 +06:00
Sergey Palcheh fe4473ef76 perf(gui): single-pass script parser, grid caches, paused offscreen timers; license parity with CLI 2026-10-15 18:59:40 +06:00
Sergey Palcheh 7c53112cc9 refactor(gui): info panel controller, tab name constants, dead code cleanup 2026-10-15 17:30:20 +06:00
Sergey Palcheh 453b5a5a99 fix(gui): icon animation leaks, GUI thread blocks, dialog crashes, Qt6 exit hang 2026-10-15 16:07:26 +06:00
Mikhail Tergoev 4990ac941a added support for grdwine system libraries 2026-10-13 13:57:31 +03:00
29 changed files with 976 additions and 685 deletions

No files matched your search

+77 -6
View File
@@ -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
-117
View File
@@ -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`.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 и связанные методы) оставлен
+62 -17
View File
@@ -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."""
+18 -8
View File
@@ -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()
-72
View File
@@ -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():
+22
View File
@@ -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):
+1 -1
View File
@@ -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)
+5 -3
View File
@@ -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)
+93 -84
View File
@@ -14,99 +14,108 @@ class ScriptParser:
"""Утилитарный класс для парсинга информации из скриптов установки."""
@staticmethod
def extract_icons_from_script(script_path):
def parse_script(script_path):
"""
Извлекает иконку для скрипта.
Извлекает всю информацию из скрипта установки за один проход по файлу.
Сначала ищет переменную 'export ICONS_BUTTON_GUI=', если не находит,
то ищет 'export PROG_ICON=', затем ищет вызовы 'create_desktop'.
Извлекает: PROG_NAME, PROG_URL, описание (# info_ru:) и иконки
(приоритет: ICONS_BUTTON_GUI > PROG_ICON > create_desktop).
Args:
script_path: Путь к скрипту установки
Returns:
Список имен иконок
Словарь с ключами prog_name, url, description, icons.
При ошибке чтения — значения по умолчанию (как у старых
отдельных методов: None / "Ошибка чтения файла: ..." / []).
"""
logger.debug("Извлекаем иконки из скрипта: %s", script_path)
logger.debug("Парсим скрипт установки: %s", script_path)
try:
with open(script_path, "r", encoding="utf-8") as f:
lines = f.readlines()
except Exception as e:
logger.warning("Не удалось прочитать скрипт %s: %s", script_path, e)
return {
"prog_name": None,
"url": None,
"description": f"Ошибка чтения файла: {str(e)}",
"icons": [],
}
# Приоритет у ICONS_BUTTON_GUI
for line in lines:
if line.strip().startswith("export ICONS_BUTTON_GUI="):
icon_names_str = line.split("=", 1)[1].strip().strip("\"'")
if icon_names_str:
icons = icon_names_str.split()
logger.debug("Найдены иконки %s в %s", icons, script_path)
return icons
# Переменные: берём первое вхождение каждой (в т.ч. пустое — как раньше
# в _extract_variable), описание — первую строку "# info_ru:"
prog_name = None
prog_url = None
description = None
prog_name_done = False
prog_url_done = False
description_done = False
for line in lines:
if line.strip().startswith("export PROG_ICON="):
icon_names_str = line.split("=", 1)[1].strip().strip("\"'")
if icon_names_str:
icons = icon_names_str.split()
logger.debug("Найдены иконки %s в %s", icons, script_path)
return icons
for line in lines:
if prog_name_done and prog_url_done and description_done:
break
parsed_name, parsed_value = ExportUtils.parse_line(line)
if not prog_name_done and parsed_name == "PROG_NAME":
prog_name = parsed_value if parsed_value else None
prog_name_done = True
if not prog_url_done and parsed_name == "PROG_URL":
prog_url = parsed_value if parsed_value else None
prog_url_done = True
if not description_done and line.startswith("# info_ru:"):
description = line.replace("# info_ru:", "").strip()
description_done = True
# Поиск в create_desktop
icon_names = []
for line in lines:
line = line.strip()
if not line or line.startswith("#"):
if description is None:
description = "Описание отсутствует"
return {
"prog_name": prog_name,
"url": prog_url,
"description": description,
"icons": ScriptParser._extract_icons_from_lines(lines, script_path),
}
@staticmethod
def _extract_icons_from_lines(lines, script_path):
"""
Ищет иконки в уже прочитанных строках скрипта.
Приоритет: ICONS_BUTTON_GUI > PROG_ICON > имена иконок из вызовов create_desktop.
"""
for line in lines:
if line.strip().startswith("export ICONS_BUTTON_GUI="):
icon_names_str = line.split("=", 1)[1].strip().strip("\"'")
if icon_names_str:
icons = icon_names_str.split()
logger.debug("Найдены иконки %s в %s", icons, script_path)
return icons
for line in lines:
if line.strip().startswith("export PROG_ICON="):
icon_names_str = line.split("=", 1)[1].strip().strip("\"'")
if icon_names_str:
icons = icon_names_str.split()
logger.debug("Найдены иконки %s в %s", icons, script_path)
return icons
# Поиск в create_desktop
icon_names = []
for line in lines:
line = line.strip()
if not line or line.startswith("#"):
continue
if "create_desktop" in line:
try:
parts = shlex.split(line)
for i, part in enumerate(parts):
if part == "create_desktop":
if len(parts) > i + 3:
icon_name = parts[i + 3]
if icon_name and icon_name.lower() not in ("auto", "nocopy"):
icon_names.append(icon_name)
except (ValueError, IndexError) as e:
logger.debug("Пропущена строка create_desktop в %s: %s", script_path, e)
continue
if "create_desktop" in line:
try:
parts = shlex.split(line)
for i, part in enumerate(parts):
if part == "create_desktop":
if len(parts) > i + 3:
icon_name = parts[i + 3]
if icon_name and icon_name.lower() not in ("auto", "nocopy"):
icon_names.append(icon_name)
except (ValueError, IndexError) as e:
logger.debug("Пропущена строка create_desktop в %s: %s", script_path, e)
continue
logger.debug("Иконки из create_desktop в %s: %s", script_path, icon_names)
return icon_names
except Exception as e:
logger.warning("Не удалось извлечь иконки из %s: %s", script_path, e)
return []
@staticmethod
def _extract_variable(script_path, var_name):
"""Извлекает значение переменной из скрипта установки."""
logger.debug("Извлекаем переменную %s из скрипта: %s", var_name, script_path)
try:
with open(script_path, "r", encoding="utf-8") as f:
for line in f:
parsed_name, parsed_value = ExportUtils.parse_line(line)
if parsed_name == var_name:
logger.debug("Найдена %s=%s в %s", var_name, parsed_value, script_path)
return parsed_value if parsed_value else None
logger.debug("Переменная %s не найдена в %s", var_name, script_path)
return None
except Exception as e:
logger.warning("Не удалось извлечь переменную %s из %s: %s", var_name, script_path, e)
return None
@staticmethod
def extract_prog_name_from_script(script_path):
"""Извлекает имя программы из строки PROG_NAME= в скрипте."""
return ScriptParser._extract_variable(script_path, "PROG_NAME")
@staticmethod
def extract_prog_url_from_script(script_path):
"""Извлекает URL из строки PROG_URL= в скрипте."""
return ScriptParser._extract_variable(script_path, "PROG_URL")
@staticmethod
def extract_info_ru(script_path):
"""Извлекает информацию из строки # info_ru: в скрипте"""
try:
with open(script_path, "r", encoding="utf-8") as f:
for line in f:
if line.startswith("# info_ru:"):
return line.replace("# info_ru:", "").strip()
return "Описание отсутствует"
except Exception as e:
logger.warning("Не удалось извлечь описание из %s: %s", script_path, e)
return f"Ошибка чтения файла: {str(e)}"
logger.debug("Иконки из create_desktop в %s: %s", script_path, icon_names)
return icon_names
+3
View File
@@ -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",
+37 -5
View File
@@ -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]:
"""
Отмечает приложение как завершённое и возвращает процесс.
+61 -47
View File
@@ -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, ""
+24 -11
View File
@@ -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:
+6 -5
View File
@@ -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)
+25
View File
@@ -0,0 +1,25 @@
"""Помощники для создания шрифтов.
Семейство шрифта нигде в GUI не задаётся явно: используется системный шрифт
пользователя (для моноширинного — systemFont(FixedFont)). Это даёт
единообразие с остальным рабочим столом и не зависит от наличия
конкретных семейств (Arial в ALT Linux отсутствует).
"""
from winehelperqt.qt_compat import QFont, QFontDatabase
def app_font(point_size: int, bold: bool = False) -> QFont:
"""Шрифт системного семейства с заданным размером и весом."""
font = QFont()
font.setPointSize(point_size)
if bold:
font.setBold(True)
return font
def fixed_font(point_size: int) -> QFont:
"""Системный моноширинный шрифт заданного размера (для логов и консолей)."""
font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
font.setPointSize(point_size)
return font
+40 -3
View File
@@ -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
+2
View File
@@ -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",
]
+3 -2
View File
@@ -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):
+25
View 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:
"""
Запускает анимацию плавного перехода между иконками.
+104
View File
@@ -0,0 +1,104 @@
"""
Контроллер правой информационной панели главного окна WineHelper.
Единая точка координации панели: заголовок и описание принадлежат контроллеру,
вкладки добавляют свои секции кнопок через add_section() и управляют
видимостью через явные методы вместо прямого доступа к чужим виджетам.
"""
from winehelperqt.qt_compat import (
QFrame,
QLabel,
QPixmap,
QSizePolicy,
Qt,
QTextBrowser,
QVBoxLayout,
)
from winehelperqt.utils.font_utils import app_font
class InfoPanelController(QFrame):
"""
Правая информационная панель главного окна.
Содержит заголовок (script_title) и описание (script_description);
секции кнопок вкладок добавляются через add_section() в порядке:
заголовок, описание, путь к установочному файлу, действия установщика
(добавляются главным окном), действия приложения, spacer, глобальные
действия «Установленные» (добавляются InstalledTab).
"""
# Индексы строк layout панели — используются в reset_stretch().
# Зависят от порядка addWidget/add_section выше; при изменении порядка
# добавления обновить и константы.
INDEX_TITLE = 0
INDEX_DESCRIPTION = 1
INDEX_MANUAL_INSTALL_PATH = 2
INDEX_INSTALL_ACTIONS = 3
INDEX_INSTALLED_ACTIONS = 4
INDEX_INSTALLED_SPACER = 5
INDEX_INSTALLED_GLOBAL_ACTIONS = 6
def __init__(self, parent=None):
"""
Инициализирует панель: рамка, шрифт, заголовок и описание.
Args:
parent: Родительский виджет (главное окно)
"""
super().__init__(parent)
self.setFrameShape(QFrame.Shape.StyledPanel)
self.setMinimumWidth(400) # Размер инф. панели
self.setFont(app_font(10)) # Размер шрифта в инф. панели
layout = QVBoxLayout(self)
# Заголовок
self.script_title = QLabel("Выберите программу")
self.script_title.setFont(app_font(12, bold=True))
self.script_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Заголовок не растягивается по вертикали, чтобы кнопка "Запустить" поднялась
self.script_title.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
# Изначально скрыт: появляется только при выборе программы
self.script_title.setVisible(False)
layout.addWidget(self.script_title)
# Описание
self.script_description = QTextBrowser()
self.script_description.setReadOnly(True)
self.script_description.setOpenExternalLinks(True)
layout.addWidget(self.script_description)
def add_section(self, widget):
"""Добавляет секцию кнопок вкладки вниз панели."""
self.layout().addWidget(widget)
def set_visible(self, visible):
"""Показывает или скрывает панель целиком."""
self.setVisible(visible)
def set_title(self, text):
"""Устанавливает текст заголовка панели."""
self.script_title.setText(text)
def set_title_icon(self, pixmap: QPixmap):
"""Устанавливает иконку заголовка панели (пустой QPixmap — без иконки)."""
self.script_title.setPixmap(pixmap)
def set_title_visible(self, visible):
"""Показывает или скрывает заголовок панели."""
self.script_title.setVisible(visible)
def set_description(self, html_content):
"""Устанавливает HTML-содержимое описания панели."""
self.script_description.setHtml(html_content)
def set_description_visible(self, visible):
"""Показывает или скрывает описание панели."""
self.script_description.setVisible(visible)
def reset_stretch(self):
"""Сбрасывает растяжение к состоянию по умолчанию: растягивается описание, а не секции с кнопками."""
self.layout().setStretch(self.INDEX_DESCRIPTION, 1)
self.layout().setStretch(self.INDEX_INSTALLED_ACTIONS, 0)
+38 -18
View File
@@ -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 # Пользователь отклонил лицензию
# Имя для заголовка диалога: из скрипта, иначе имя файла
+82 -52
View File
@@ -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()
+65 -55
View File
@@ -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 (терминал)."""
+19
View File
@@ -0,0 +1,19 @@
"""
Отображаемые имена вкладок главного окна WineHelper.
Единый источник строк для логики переключения вкладок: сравнения в
обработчиках и вызовах add_tab используют только эти константы,
чтобы переименование вкладки не ломало поведение молча.
"""
class TabNames:
"""Имена вкладок главного окна (как они отображаются в TabBar)."""
AUTO_INSTALL = "Автоматическая установка"
MANUAL_INSTALL = "Ручная установка"
EXTERNAL_INSTALL = "Сторонняя установка"
INSTALLED = "Установленные"
PREFIX_MANAGER = "Менеджер префиксов"
SETTINGS = "Настройки"
HELP = "Справка"