From 16b9a84191f04b82321ad1bcebeec19fa7ff6f48 Mon Sep 17 00:00:00 2001 From: Sergey Palcheh Date: Thu, 14 Aug 2025 13:15:40 +0600 Subject: [PATCH] added system dependency check --- winehelper_gui.py | 410 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 403 insertions(+), 7 deletions(-) diff --git a/winehelper_gui.py b/winehelper_gui.py index c01851a..2e56df6 100644 --- a/winehelper_gui.py +++ b/winehelper_gui.py @@ -28,6 +28,396 @@ class Var: LICENSE_FILE = os.environ.get("LICENSE_FILE") LICENSE_AGREEMENT_FILE = os.environ.get("AGREEMENT") +class DependencyManager: + """Класс для управления проверкой и установкой системных зависимостей.""" + + def __init__(self): + """Инициализирует менеджер, определяя необходимые пути.""" + self.dependencies_script_path = self._get_dependencies_path() + self.config_dir = os.path.join(os.path.expanduser("~"), ".config", "winehelper") + self.hash_flag_file = os.path.join(self.config_dir, "dependencies_hash.txt") + os.makedirs(self.config_dir, exist_ok=True) + self.app_icon = QIcon(Var.WH_ICON_PATH) if Var.WH_ICON_PATH and os.path.exists(Var.WH_ICON_PATH) else QIcon() + + def _get_dependencies_path(self): + """Определяет и возвращает путь к скрипту dependencies.sh.""" + winehelper_script_path = os.environ.get("RUN_SCRIPT") + if winehelper_script_path and os.path.exists(winehelper_script_path): + return os.path.join(os.path.dirname(winehelper_script_path), 'dependencies.sh') + return None + + def _calculate_file_hash(self): + """Вычисляет хэш SHA256 файла зависимостей.""" + if not self.dependencies_script_path or not os.path.exists(self.dependencies_script_path): + return None + hasher = hashlib.sha256() + try: + with open(self.dependencies_script_path, 'rb') as f: + while chunk := f.read(4096): + hasher.update(chunk) + return hasher.hexdigest() + except IOError: + return None + + def _parse_dependencies_from_script(self): + """ + Парсит скрипт dependencies.sh для извлечения списка базовых пакетов. + Возвращает список пакетов или None в случае ошибки. + """ + if not os.path.exists(self.dependencies_script_path): + return None + + base_packages = [] + try: + with open(self.dependencies_script_path, 'r', encoding='utf-8') as f: + content = f.read() + + content = content.replace('\\\n', '') + pattern = r'apt-get install\s+\{i586-,\}(\{.*?\}|[\w.-]+)' + matches = re.findall(pattern, content) + + for match in matches: + match = match.strip() + if match.startswith('{') and match.endswith('}'): + group_content = match[1:-1] + packages = [pkg.strip() for pkg in group_content.split(',')] + base_packages.extend(packages) + else: + base_packages.append(match) + + return sorted(list(set(pkg for pkg in base_packages if pkg))) or None + except Exception: + return None + + def _parse_repo_error_from_script(self): + """ + Парсит скрипт dependencies.sh для извлечения сообщения об ошибке репозитория. + Возвращает сообщение или None в случае ошибки. + """ + if not os.path.exists(self.dependencies_script_path): + return None + + try: + with open(self.dependencies_script_path, 'r', encoding='utf-8') as f: + content = f.read() + + content = content.replace('\\\n', ' ') + match = re.search(r'apt-repo.*\|\|.*fatal\s+"([^"]+)"', content) + if match: + error_message = match.group(1).strip() + return re.sub(r'\s+', ' ', error_message) + return None + except Exception: + return None + + def _show_startup_messages(self): + """ + Проверяет, является ли это первым запуском или обновлением, + и показывает соответствующие сообщения. + """ + current_hash = self._calculate_file_hash() + stored_hash = None + hash_file_exists = os.path.exists(self.hash_flag_file) + + if hash_file_exists: + try: + with open(self.hash_flag_file, 'r', encoding='utf-8') as f: + stored_hash = f.read().strip() + except IOError: + pass + + if not hash_file_exists: + msg_box = QMessageBox(QMessageBox.Information, "Первый запуск WineHelper", + "Поскольку это первый запуск, программа проверит наличие необходимых системных зависимостей.") + msg_box.setWindowIcon(self.app_icon) + msg_box.exec_() + elif current_hash != stored_hash: + msg_box = QMessageBox(QMessageBox.Information, "Обновление зависимостей", + "Обнаружены изменения в системных требованиях.\n\n" + "Программа выполнит проверку, чтобы убедиться, что все компоненты на месте.") + msg_box.setWindowIcon(self.app_icon) + msg_box.exec_() + + def _save_dependency_hash(self): + """Сохраняет хэш зависимостей в конфигурационный файл.""" + current_hash = self._calculate_file_hash() + if not current_hash: + return + try: + with open(self.hash_flag_file, 'w', encoding='utf-8') as f: + f.write(current_hash) + except IOError: + print("Предупреждение: не удалось записать файл с хэшем зависимостей.") + + def _perform_check_and_install(self): + """ + Выполняет основную логику проверки и установки зависимостей. + Возвращает True в случае успеха, иначе False. + """ + # Проверка наличия ключевых утилит + if not shutil.which('apt-repo') or not shutil.which('rpm'): + return True + + if not self.dependencies_script_path or not os.path.exists(self.dependencies_script_path): + msg_box = QMessageBox(QMessageBox.Critical, "Критическая ошибка", + f"Файл зависимостей не найден по пути:\n'{self.dependencies_script_path}'.\n\n" + "Программа не может продолжить работу." + ) + msg_box.setWindowIcon(self.app_icon) + msg_box.exec_() + return False + + # 1. Проверка наличия репозитория x86_64-i586 + try: + result = subprocess.run(['apt-repo'], capture_output=True, text=True, check=False, encoding='utf-8') + if result.returncode != 0 or 'x86_64-i586' not in result.stdout: + error_message = self._parse_repo_error_from_script() + if not error_message: + msg_box = QMessageBox(QMessageBox.Critical, "Критическая ошибка", + f"Репозиторий x86_64-i586 не подключен, но не удалось извлечь текст ошибки из файла:\n'{self.dependencies_script_path}'.\n\n" + "Проверьте целостность скрипта. Работа программы будет прекращена." + ) + msg_box.setWindowIcon(self.app_icon) + msg_box.exec_() + return False + + msg_box = QMessageBox(QMessageBox.Critical, "Ошибка репозитория", + f"{error_message}\n\nРабота программы будет прекращена.") + msg_box.setWindowIcon(self.app_icon) + msg_box.exec_() + return False + except FileNotFoundError: + return True + + # 2. Определение списка пакетов из dependencies.sh + base_packages = self._parse_dependencies_from_script() + + # Если парсинг не удался или скрипт не найден, прерываем работу. + if not base_packages: + msg_box = QMessageBox(QMessageBox.Critical, "Критическая ошибка", + f"Не удалось найти или проанализировать файл зависимостей:\n'{self.dependencies_script_path}'.\n\n" + "Программа не может продолжить работу без списка необходимых пакетов." + ) + msg_box.setWindowIcon(self.app_icon) + msg_box.exec_() + return False + + required_packages = [f"i586-{pkg}" for pkg in base_packages] + base_packages + + # 3. Проверка, какие пакеты отсутствуют + try: + # Запрашиваем список всех установленных пакетов один раз для эффективности + result = subprocess.run( + ['rpm', '-qa', '--queryformat', '%{NAME}\n'], + capture_output=True, text=True, check=True, encoding='utf-8' + ) + installed_packages_set = set(result.stdout.splitlines()) + required_packages_set = set(required_packages) + + # Находим разницу между требуемыми и установленными пакетами + missing_packages = sorted(list(required_packages_set - installed_packages_set)) + + except (subprocess.CalledProcessError, FileNotFoundError) as e: + # В случае ошибки (например, rpm не найден), показываем критическое сообщение + msg_box = QMessageBox(QMessageBox.Critical, "Критическая ошибка", + f"Не удалось получить список установленных пакетов с помощью rpm.\n\nОшибка: {e}\n\n" + "Программа не может проверить зависимости и будет закрыта." + ) + msg_box.setWindowIcon(self.app_icon) + msg_box.exec_() + return False + + if not missing_packages: + return True + + # 4. Запрос у пользователя на установку + msg_box = QMessageBox() + msg_box.setWindowIcon(self.app_icon) + msg_box.setIcon(QMessageBox.Warning) + msg_box.setWindowTitle("Отсутствуют зависимости") + # Устанавливаем формат текста в RichText, чтобы QMessageBox корректно обрабатывал HTML-теги. + msg_box.setTextFormat(Qt.RichText) + + # Формируем весь текст как единый HTML-блок для корректного отображения. + full_html_text = ( + "Для корректной работы WineHelper требуются дополнительные системные компоненты.

" + "Отсутствуют следующие пакеты:
" + # Ограничиваем высоту блока с пакетами и добавляем прокрутку, если список длинный. + f"""
+ {'
'.join(sorted(missing_packages))} +
""" + "Нажмите 'Установить', чтобы запустить установку с правами администратора. " + "Вам потребуется ввести пароль. Этот процесс может занять некоторое время." + ) + msg_box.setText(full_html_text) + install_button = msg_box.addButton("Установить", QMessageBox.AcceptRole) + cancel_button = msg_box.addButton("Отмена", QMessageBox.RejectRole) + msg_box.setDefaultButton(install_button) + + msg_box.exec_() + + if msg_box.clickedButton() != install_button: + cancel_box = QMessageBox(QMessageBox.Warning, "Отмена", + "Установка отменена.\n\n" + "WineHelper не может продолжить работу\nбез необходимых зависимостей.") + cancel_box.setWindowIcon(self.app_icon) + cancel_box.exec_() + return False + + # 5. Запуск установки с отображением лога в диалоговом окне + if not shutil.which('pkexec'): + error_box = QMessageBox(QMessageBox.Critical, "Ошибка", + "Не найден компонент 'pkexec' для повышения прав.\n" + "Пожалуйста, установите пакет 'polkit-pkexec', " + f"а затем установите зависимости вручную:\n\n sudo apt-get install {' '.join(missing_packages)}" + ) + error_box.setWindowIcon(self.app_icon) + error_box.exec_() + return False + + install_cmd_str = f"apt-get update && apt-get install -y {' '.join(missing_packages)}" + + # Этот флаг будет установлен в True, если установка и проверка пройдут успешно. + installation_successful = False + + # Создаем диалог для вывода лога установки + dialog = QDialog() + dialog.setWindowIcon(self.app_icon) + dialog.setWindowTitle("Установка зависимостей") + dialog.setMinimumSize(680, 400) + dialog.setModal(True) + + # Центрирование окна по центру экрана + screen_geometry = QApplication.primaryScreen().availableGeometry() + dialog.move( + (screen_geometry.width() - dialog.width()) // 2, + (screen_geometry.height() - dialog.height()) // 2 + ) + + layout = QVBoxLayout(dialog) + log_output = QTextEdit() + log_output.setReadOnly(True) + log_output.setFont(QFont("DejaVu Sans Mono", 10)) + layout.addWidget(log_output) + + close_button = QPushButton("Закрыть") + close_button.setEnabled(False) + close_button.clicked.connect(dialog.accept) + layout.addWidget(close_button) + + process = QProcess(dialog) + process.setProcessChannelMode(QProcess.MergedChannels) + + def handle_output(): + # Используем insertPlainText для корректного отображения потокового вывода (например, прогресс-баров) + log_output.insertPlainText(process.readAll().data().decode('utf-8', 'ignore')) + log_output.moveCursor(QTextCursor.End) + + def handle_finish(exit_code, exit_status): + nonlocal installation_successful + log_output.moveCursor(QTextCursor.End) + if exit_code == 0 and exit_status == QProcess.NormalExit: + log_output.append("\n=== Установка успешно завершена ===") + log_output.ensureCursorVisible() + + # Повторная проверка зависимостей сразу после установки + try: + result = subprocess.run( + ['rpm', '-qa', '--queryformat', '%{NAME}\n'], + capture_output=True, text=True, check=True, encoding='utf-8' + ) + installed_packages_set = set(result.stdout.splitlines()) + missing_packages_set = set(missing_packages) + still_missing = sorted(list(missing_packages_set - installed_packages_set)) + except (subprocess.CalledProcessError, FileNotFoundError): + warn_box = QMessageBox(dialog) + warn_box.setWindowIcon(self.app_icon) + warn_box.setIcon(QMessageBox.Warning) + warn_box.setWindowTitle("Проверка не удалась") + warn_box.setText("Не удалось повторно проверить зависимости после установки.") + warn_box.exec_() + still_missing = missing_packages + + if not still_missing: + info_box = QMessageBox(dialog) + info_box.setWindowIcon(self.app_icon) + info_box.setIcon(QMessageBox.Information) + info_box.setWindowTitle("Успех") + info_box.setText("Все необходимые зависимости были успешно установлены.") + info_box.exec_() + installation_successful = True + else: + warn_box = QMessageBox(dialog) + warn_box.setWindowIcon(self.app_icon) + warn_box.setIcon(QMessageBox.Warning) + warn_box.setWindowTitle("Установка не завершена") + warn_box.setText( + "Не все зависимости были установлены. WineHelper может работать некорректно.\n\n" + f"Отсутствуют: {', '.join(sorted(still_missing))}\n\n" + "Попробуйте запустить установку снова или установите пакеты вручную." + ) + warn_box.exec_() + else: + log_tag = "ПРЕРВАНО" if exit_status == QProcess.CrashExit else "ОШИБКА" + log_output.append(f"\n=== {log_tag} (код: {exit_code}) ===") + log_output.ensureCursorVisible() + + close_button.setEnabled(True) + + def dialog_close_handler(event): + """Обрабатывает закрытие окна во время установки зависимостей.""" + if process.state() == QProcess.Running: + msg_box = QMessageBox(dialog) + msg_box.setMinimumWidth(900) + msg_box.setIcon(QMessageBox.Question) + msg_box.setWindowTitle("Прервать установку?") + msg_box.setText( + "

Установка зависимостей еще не завершена.

" + "

Вы действительно хотите прервать процесс?

" + "

Это закроет только окно программы.
" + "Процесс установки зависимостей будет продолжен.

" + ) + + yes_button = msg_box.addButton("Да, прервать", QMessageBox.YesRole) + no_button = msg_box.addButton("Нет", QMessageBox.NoRole) + msg_box.setDefaultButton(no_button) + + msg_box.exec_() + + if msg_box.clickedButton() == yes_button: + process.readyRead.disconnect() + process.finished.disconnect() + process.terminate() + event.accept() + else: + event.ignore() + else: + event.accept() + + process.readyRead.connect(handle_output) + process.finished.connect(handle_finish) + + log_output.append(f"Выполнение команды:\npkexec sh -c \"{install_cmd_str}\"\n") + log_output.append("Пожалуйста, введите пароль в появившемся окне аутентификации...") + log_output.append("-" * 40 + "\n") + + dialog.closeEvent = dialog_close_handler + process.start('pkexec', ['sh', '-c', install_cmd_str]) + dialog.exec_() + + return installation_successful + + def run(self): + """ + Основной публичный метод для запуска полной проверки зависимостей. + Возвращает True, если все зависимости удовлетворены, иначе False. + """ + self._show_startup_messages() + if self._perform_check_and_install(): + self._save_dependency_hash() + return True + return False + class WinetricksManagerDialog(QDialog): """Диалог для управления компонентами Winetricks.""" @@ -542,7 +932,7 @@ class WineHelperGUI(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("WineHelper") - self.setGeometry(100, 100, 950, 500) + self.setMinimumSize(950, 500) if Var.WH_ICON_PATH and os.path.exists(Var.WH_ICON_PATH): self.setWindowIcon(QIcon(Var.WH_ICON_PATH)) @@ -2258,17 +2648,23 @@ class WineHelperGUI(QMainWindow): if hasattr(self, 'install_process') and self.install_process: if self.install_process.state() == QProcess.Running: self.install_process.terminate() - # Даем процессу 3 секунды на завершение if not self.install_process.waitForFinished(3000): self.append_log("Процесс не ответил на terminate, отправляется kill...", is_error=True) self.install_process.kill() - self.install_process.waitForFinished() # Ждем завершения после kill + self.install_process.waitForFinished() self.install_process.deleteLater() self.install_process = None +def main(): + """Основная точка входа в приложение.""" + app = QApplication(sys.argv) + + dependency_manager = DependencyManager() + if dependency_manager.run(): + window = WineHelperGUI() + window.show() + return app.exec_() + return 1 if __name__ == "__main__": - app = QApplication(sys.argv) - window = WineHelperGUI() - window.show() - sys.exit(app.exec_()) + sys.exit(main())