Compare commits
	
		
			14 Commits
		
	
	
		
			9452bfda2e
			...
			4d6f32f053
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 4d6f32f053 | |||
| a2f5141b20 | |||
| e3cb2857e7 | |||
| efe8a35832 | |||
| 61fae97dad | |||
| 5442100f64 | |||
| 2d6ef84798 | |||
|  | f4aee15b5d | ||
| 87a65108a5 | |||
| bb617708ac | |||
| 1cf332cd87 | |||
| 577ad4d3a3 | |||
| ef3f2d6e96 | |||
| 657d7728a6 | 
| @@ -16,7 +16,7 @@ repos: | ||||
|       - id: uv-lock | ||||
|  | ||||
|   - repo: https://github.com/astral-sh/ruff-pre-commit | ||||
|     rev: v0.13.2 | ||||
|     rev: v0.14.0 | ||||
|     hooks: | ||||
|       - id: ruff-check | ||||
|  | ||||
|   | ||||
| @@ -10,7 +10,9 @@ | ||||
| - Импорт и экспорт бекапа префикса | ||||
| - Диалог для управление Winetricks | ||||
| - Кнопки для удаления префикса, wine или proton | ||||
| - Все настройки Wine с оригинального PortProton | ||||
| - Виртуальная клавиатура в диалог добавления игры и поиск по библиотеке | ||||
| - Вкладка автоустановок | ||||
|  | ||||
| ### Changed | ||||
| - Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр | ||||
| @@ -22,6 +24,8 @@ | ||||
| - Исправлено зависание при поиске игр | ||||
| - Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity) | ||||
| - Исправлена ошибка из-за которой подсказки по управлению снизу и сверху могли не совпадать с друг другом, из-за чего возле вкладок были стрелки клавиатуры, а снизу кнопки геймпада | ||||
| - Исправлен выход из полноэкранного режима при отключении геймпада подключённого по USB даже если настройка "Режим полноэкранного отображения приложения при подключении геймпада" выключена | ||||
| - При сохранении настроек теперь не меняется размер окна | ||||
|  | ||||
| ### Contributors | ||||
|  | ||||
|   | ||||
| @@ -21,9 +21,9 @@ Current translation status: | ||||
|  | ||||
| | Locale | Progress | Translated | | ||||
| | :----- | -------: | ---------: | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 233 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 233 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 233 of 233 | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 239 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 239 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 239 of 239 | | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -21,9 +21,9 @@ | ||||
|  | ||||
| | Локаль | Прогресс | Переведено | | ||||
| | :----- | -------: | ---------: | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 233 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 233 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 233 из 233 | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 239 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 239 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 239 из 239 | | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| import sys | ||||
| import os | ||||
| import subprocess | ||||
| from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo | ||||
| from PySide6.QtWidgets import QApplication | ||||
| from PySide6.QtGui import QIcon | ||||
| from portprotonqt.main_window import MainWindow | ||||
| from portprotonqt.config_utils import save_fullscreen_config | ||||
| from portprotonqt.config_utils import save_fullscreen_config, get_portproton_location | ||||
| from portprotonqt.logger import get_logger, setup_logger | ||||
| from portprotonqt.cli import parse_args | ||||
|  | ||||
| @@ -12,6 +14,19 @@ __app_name__ = "PortProtonQt" | ||||
| __app_version__ = "0.1.6" | ||||
|  | ||||
| def main(): | ||||
|  | ||||
|     os.environ['PW_CLI'] = '1' | ||||
|     os.environ['PROCESS_LOG'] = '1' | ||||
|     os.environ['START_FROM_STEAM'] = '1' | ||||
|  | ||||
|     portproton_path = get_portproton_location() | ||||
|  | ||||
|     if portproton_path is None: | ||||
|         return | ||||
|  | ||||
|     script_path = os.path.join(portproton_path, 'data', 'scripts', 'start.sh') | ||||
|     subprocess.run([script_path, 'cli', '--initial']) | ||||
|  | ||||
|     app = QApplication(sys.argv) | ||||
|     app.setWindowIcon(QIcon.fromTheme(__app_id__)) | ||||
|     app.setDesktopFileName(__app_id__) | ||||
|   | ||||
| Before Width: | Height: | Size: 634 KiB After Width: | Height: | Size: 634 KiB | 
| Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 315 KiB | 
| Before Width: | Height: | Size: 978 KiB After Width: | Height: | Size: 978 KiB | 
| Before Width: | Height: | Size: 650 KiB After Width: | Height: | Size: 650 KiB | 
| Before Width: | Height: | Size: 391 KiB After Width: | Height: | Size: 391 KiB | 
| Before Width: | Height: | Size: 710 KiB After Width: | Height: | Size: 710 KiB | 
| Before Width: | Height: | Size: 627 KiB After Width: | Height: | Size: 627 KiB | 
| @@ -453,3 +453,11 @@ class GameLibraryManager: | ||||
|     def filter_games_delayed(self): | ||||
|         """Filters games based on search text and updates the grid.""" | ||||
|         self.update_game_grid(is_filter=True) | ||||
|  | ||||
|     def calculate_columns(self, card_width: int) -> int: | ||||
|         """Calculate the number of columns based on card width and assumed container width.""" | ||||
|         # Assuming a typical container width; adjust as needed | ||||
|         available_width = 1200  # Example width, can be dynamic if widget access is added | ||||
|         spacing = 15  # Assumed spacing between cards | ||||
|         columns = max(1, (available_width - spacing) // (card_width + spacing)) | ||||
|         return min(columns, 8)  # Cap at reasonable max | ||||
|   | ||||
| @@ -38,6 +38,7 @@ class MainWindowProtocol(Protocol): | ||||
|     stackedWidget: QStackedWidget | ||||
|     tabButtons: dict[int, QWidget] | ||||
|     gamesListWidget: QWidget | ||||
|     autoInstallContainer: QWidget | None | ||||
|     currentDetailPage: QWidget | None | ||||
|     current_exec_line: str | None | ||||
|     current_add_game_dialog: AddGameDialog | None | ||||
| @@ -91,6 +92,7 @@ class InputManager(QObject): | ||||
|         self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None) | ||||
|         self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None) | ||||
|         self._parent.current_add_game_dialog = getattr(self._parent, 'current_add_game_dialog', None) | ||||
|         self._parent.autoInstallContainer = getattr(self._parent, 'autoInstallContainer', None) | ||||
|         self.axis_deadzone = axis_deadzone | ||||
|         self.initial_axis_move_delay = initial_axis_move_delay | ||||
|         self.repeat_axis_move_delay = repeat_axis_move_delay | ||||
| @@ -143,6 +145,132 @@ class InputManager(QObject): | ||||
|         # Initialize evdev + hotplug | ||||
|         self.init_gamepad() | ||||
|  | ||||
|     def _navigate_game_cards(self, container, tab_index: int, code: int, value: int) -> None: | ||||
|         """Common navigation logic for game cards in a container.""" | ||||
|         if container is None: | ||||
|             return | ||||
|         focused = QApplication.focusWidget() | ||||
|         game_cards = container.findChildren(GameCard) | ||||
|         if not game_cards: | ||||
|             return | ||||
|  | ||||
|         scroll_area = container.parentWidget() | ||||
|         while scroll_area and not isinstance(scroll_area, QScrollArea): | ||||
|             scroll_area = scroll_area.parentWidget() | ||||
|  | ||||
|         # If no focused widget or not a GameCard, focus the first card | ||||
|         if not isinstance(focused, GameCard) or focused not in game_cards: | ||||
|             game_cards[0].setFocus() | ||||
|             if scroll_area: | ||||
|                 scroll_area.ensureWidgetVisible(game_cards[0], 50, 50) | ||||
|             return | ||||
|  | ||||
|         cards = container.findChildren(GameCard, options=Qt.FindChildOption.FindChildrenRecursively) | ||||
|         if not cards: | ||||
|             return | ||||
|         # Group cards by rows with tolerance for y-position | ||||
|         rows = {} | ||||
|         y_tolerance = 10  # Allow slight variations in y-position | ||||
|         for card in cards: | ||||
|             y = card.pos().y() | ||||
|             matched = False | ||||
|             for row_y in rows: | ||||
|                 if abs(y - row_y) <= y_tolerance: | ||||
|                     rows[row_y].append(card) | ||||
|                     matched = True | ||||
|                     break | ||||
|             if not matched: | ||||
|                 rows[y] = [card] | ||||
|         sorted_rows = sorted(rows.items(), key=lambda x: x[0]) | ||||
|         if not sorted_rows: | ||||
|             return | ||||
|         current_row_idx = None | ||||
|         current_col_idx = None | ||||
|         for row_idx, (_y, row_cards) in enumerate(sorted_rows): | ||||
|             for idx, card in enumerate(row_cards): | ||||
|                 if card == focused: | ||||
|                     current_row_idx = row_idx | ||||
|                     current_col_idx = idx | ||||
|                     break | ||||
|             if current_row_idx is not None: | ||||
|                 break | ||||
|  | ||||
|         # Fallback: if focused card not found, select closest row by y-position | ||||
|         if current_row_idx is None: | ||||
|             if not sorted_rows:  # Additional safety check | ||||
|                 return | ||||
|             focused_y = focused.pos().y() | ||||
|             current_row_idx = min(range(len(sorted_rows)), key=lambda i: abs(sorted_rows[i][0] - focused_y)) | ||||
|             if current_row_idx >= len(sorted_rows):  # Safety check | ||||
|                 return | ||||
|             current_row = sorted_rows[current_row_idx][1] | ||||
|             focused_x = focused.pos().x() + focused.width() / 2 | ||||
|             current_col_idx = min(range(len(current_row)), key=lambda i: abs((current_row[i].pos().x() + current_row[i].width() / 2) - focused_x), default=0)  # type: ignore | ||||
|  | ||||
|         # Add null checks before using current_row_idx and current_col_idx | ||||
|         if current_row_idx is None or current_col_idx is None or current_row_idx >= len(sorted_rows): | ||||
|             return | ||||
|  | ||||
|         current_row = sorted_rows[current_row_idx][1] | ||||
|         if code == ecodes.ABS_HAT0X and value != 0: | ||||
|             if value < 0:  # Left | ||||
|                 if current_col_idx > 0: | ||||
|                     next_card = current_row[current_col_idx - 1] | ||||
|                     next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                     if scroll_area: | ||||
|                         scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                 else: | ||||
|                     if current_row_idx > 0: | ||||
|                         prev_row = sorted_rows[current_row_idx - 1][1] | ||||
|                         next_card = prev_row[-1] if prev_row else None | ||||
|                         if next_card: | ||||
|                             next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                             if scroll_area: | ||||
|                                 scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|             elif value > 0:  # Right | ||||
|                 if current_col_idx < len(current_row) - 1: | ||||
|                     next_card = current_row[current_col_idx + 1] | ||||
|                     next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                     if scroll_area: | ||||
|                         scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                 else: | ||||
|                     if current_row_idx < len(sorted_rows) - 1: | ||||
|                         next_row = sorted_rows[current_row_idx + 1][1] | ||||
|                         next_card = next_row[0] if next_row else None | ||||
|                         if next_card: | ||||
|                             next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                             if scroll_area: | ||||
|                                 scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|         elif code == ecodes.ABS_HAT0Y and value != 0: | ||||
|             if value > 0:  # Down | ||||
|                 if current_row_idx < len(sorted_rows) - 1: | ||||
|                     next_row = sorted_rows[current_row_idx + 1][1] | ||||
|                     current_x = focused.pos().x() + focused.width() / 2 | ||||
|                     next_card = min( | ||||
|                         next_row, | ||||
|                         key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x), | ||||
|                         default=None | ||||
|                     ) | ||||
|                     if next_card: | ||||
|                         next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                         if scroll_area: | ||||
|                             scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|             elif value < 0:  # Up | ||||
|                 if current_row_idx > 0: | ||||
|                     prev_row = sorted_rows[current_row_idx - 1][1] | ||||
|                     current_x = focused.pos().x() + focused.width() / 2 | ||||
|                     next_card = min( | ||||
|                         prev_row, | ||||
|                         key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x), | ||||
|                         default=None | ||||
|                     ) | ||||
|                     if next_card: | ||||
|                         next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                         if scroll_area: | ||||
|                             scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                 elif current_row_idx == 0: | ||||
|                     self._parent.tabButtons[tab_index].setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|  | ||||
|     def detect_gamepad_type(self, device: InputDevice) -> GamepadType: | ||||
|         """ | ||||
|         Определяет тип геймпада по capabilities | ||||
| @@ -483,17 +611,27 @@ class InputManager(QObject): | ||||
|             if not app or not active: | ||||
|                 return | ||||
|  | ||||
|             current_tab_index = self._parent.stackedWidget.currentIndex() | ||||
|  | ||||
|             if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit): | ||||
|                 search_edit = getattr(self._parent, 'searchEdit', None) | ||||
|                 search_edit = None | ||||
|                 if current_tab_index == 0: | ||||
|                     search_edit = getattr(self._parent, 'searchEdit', None) | ||||
|                 elif current_tab_index == 1: | ||||
|                     search_edit = getattr(self._parent, 'autoInstallSearchLineEdit', None) | ||||
|                 if focused == search_edit: | ||||
|                     keyboard = getattr(self._parent, 'keyboard', None) | ||||
|                     if keyboard: | ||||
|                         keyboard.show_for_widget(focused) | ||||
|                         return | ||||
|  | ||||
|                 # Handle Y button to focus search | ||||
|             # Handle Y button to focus search | ||||
|             if button_code in BUTTONS['prev_dir']:  # Y button | ||||
|                 search_edit = getattr(self._parent, 'searchEdit', None) | ||||
|                 search_edit = None | ||||
|                 if current_tab_index == 0: | ||||
|                     search_edit = getattr(self._parent, 'searchEdit', None) | ||||
|                 elif current_tab_index == 1: | ||||
|                     search_edit = getattr(self._parent, 'autoInstallSearchLineEdit', None) | ||||
|                 if search_edit: | ||||
|                     search_edit.setFocus() | ||||
|                     return | ||||
| @@ -757,32 +895,6 @@ class InputManager(QObject): | ||||
|             if not app or not active: | ||||
|                 return | ||||
|  | ||||
|             # Новый код: обработка перехода на поле поиска | ||||
|             if code == ecodes.ABS_HAT0Y and value < 0:  # Only D-pad up | ||||
|                 if isinstance(focused, GameCard): | ||||
|                     # Get all visible game cards | ||||
|                     game_cards = self._parent.gamesListWidget.findChildren(GameCard) | ||||
|                     if not game_cards: | ||||
|                         return | ||||
|  | ||||
|                     # Find the current card's position | ||||
|                     current_card_pos = focused.pos() | ||||
|                     current_row_y = current_card_pos.y() | ||||
|  | ||||
|                     # Check if this is the first row (no cards above) | ||||
|                     is_first_row = True | ||||
|                     for card in game_cards: | ||||
|                         if card.pos().y() < current_row_y and card.isVisible(): | ||||
|                             is_first_row = False | ||||
|                             break | ||||
|  | ||||
|                     # Only move to search if on first row | ||||
|                     if is_first_row: | ||||
|                         search_edit = getattr(self._parent, 'searchEdit', None) | ||||
|                         if search_edit: | ||||
|                             search_edit.setFocus() | ||||
|                             return | ||||
|  | ||||
|             # Handle SystemOverlay, AddGameDialog, or QMessageBox navigation with D-pad | ||||
|             if isinstance(active, QDialog) and code == ecodes.ABS_HAT0X and value != 0: | ||||
|                 if isinstance(active, QMessageBox):  # Specific handling for QMessageBox | ||||
| @@ -898,132 +1010,43 @@ class InputManager(QObject): | ||||
|                     focused.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                     return | ||||
|  | ||||
|             # Library tab navigation (index 0) | ||||
|             if self._parent.stackedWidget.currentIndex() == 0 and code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y): | ||||
|             # Search focus logic for tabs 0 and 1 | ||||
|             if code == ecodes.ABS_HAT0Y and value < 0: | ||||
|                 focused = QApplication.focusWidget() | ||||
|                 game_cards = self._parent.gamesListWidget.findChildren(GameCard) | ||||
|                 if not game_cards: | ||||
|                     return | ||||
|                 current_index = self._parent.stackedWidget.currentIndex() | ||||
|                 if current_index in (0, 1) and isinstance(focused, GameCard): | ||||
|                     if current_index == 0: | ||||
|                         container = self._parent.gamesListWidget | ||||
|                         search_edit = getattr(self._parent, 'searchEdit', None) | ||||
|                     else: | ||||
|                         container = self._parent.autoInstallContainer | ||||
|                         search_edit = getattr(self._parent, 'autoInstallSearchLineEdit', None) | ||||
|                     if container and search_edit: | ||||
|                         game_cards = container.findChildren(GameCard) | ||||
|                         if game_cards: | ||||
|                             current_card_pos = focused.pos() | ||||
|                             current_row_y = current_card_pos.y() | ||||
|                             is_first_row = True | ||||
|                             for card in game_cards: | ||||
|                                 if card.pos().y() < current_row_y and card.isVisible(): | ||||
|                                     is_first_row = False | ||||
|                                     break | ||||
|                             if is_first_row: | ||||
|                                 search_edit.setFocus() | ||||
|                                 return | ||||
|  | ||||
|                 scroll_area = self._parent.gamesListWidget.parentWidget() | ||||
|                 while scroll_area and not isinstance(scroll_area, QScrollArea): | ||||
|                     scroll_area = scroll_area.parentWidget() | ||||
|  | ||||
|                 # If no focused widget or not a GameCard, focus the first card | ||||
|                 if not isinstance(focused, GameCard) or focused not in game_cards: | ||||
|                     game_cards[0].setFocus() | ||||
|                     if scroll_area: | ||||
|                         scroll_area.ensureWidgetVisible(game_cards[0], 50, 50) | ||||
|                     return | ||||
|  | ||||
|                 cards = self._parent.gamesListWidget.findChildren(GameCard, options=Qt.FindChildOption.FindChildrenRecursively) | ||||
|                 if not cards: | ||||
|                     return | ||||
|                 # Group cards by rows with tolerance for y-position | ||||
|                 rows = {} | ||||
|                 y_tolerance = 10  # Allow slight variations in y-position | ||||
|                 for card in cards: | ||||
|                     y = card.pos().y() | ||||
|                     matched = False | ||||
|                     for row_y in rows: | ||||
|                         if abs(y - row_y) <= y_tolerance: | ||||
|                             rows[row_y].append(card) | ||||
|                             matched = True | ||||
|                             break | ||||
|                     if not matched: | ||||
|                         rows[y] = [card] | ||||
|                 sorted_rows = sorted(rows.items(), key=lambda x: x[0]) | ||||
|                 if not sorted_rows: | ||||
|                     return | ||||
|                 current_row_idx = None | ||||
|                 current_col_idx = None | ||||
|                 for row_idx, (_y, row_cards) in enumerate(sorted_rows): | ||||
|                     for idx, card in enumerate(row_cards): | ||||
|                         if card == focused: | ||||
|                             current_row_idx = row_idx | ||||
|                             current_col_idx = idx | ||||
|                             break | ||||
|                     if current_row_idx is not None: | ||||
|                         break | ||||
|  | ||||
|                 # Fallback: if focused card not found, select closest row by y-position | ||||
|                 if current_row_idx is None: | ||||
|                     if not sorted_rows:  # Additional safety check | ||||
|             # Game cards navigation for tabs 0 and 1 | ||||
|             if code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y): | ||||
|                 current_index = self._parent.stackedWidget.currentIndex() | ||||
|                 if current_index in (0, 1): | ||||
|                     container = self._parent.gamesListWidget if current_index == 0 else self._parent.autoInstallContainer | ||||
|                     if container is None: | ||||
|                         return | ||||
|                     focused_y = focused.pos().y() | ||||
|                     current_row_idx = min(range(len(sorted_rows)), key=lambda i: abs(sorted_rows[i][0] - focused_y)) | ||||
|                     if current_row_idx >= len(sorted_rows):  # Safety check | ||||
|                         return | ||||
|                     current_row = sorted_rows[current_row_idx][1] | ||||
|                     focused_x = focused.pos().x() + focused.width() / 2 | ||||
|                     current_col_idx = min(range(len(current_row)), key=lambda i: abs((current_row[i].pos().x() + current_row[i].width() / 2) - focused_x), default=0) # type: ignore | ||||
|  | ||||
|                 # Add null checks before using current_row_idx and current_col_idx | ||||
|                 if current_row_idx is None or current_col_idx is None or current_row_idx >= len(sorted_rows): | ||||
|                     self._navigate_game_cards(container, current_index, code, value) | ||||
|                     return | ||||
|  | ||||
|                 current_row = sorted_rows[current_row_idx][1] | ||||
|                 if code == ecodes.ABS_HAT0X and value != 0: | ||||
|                     if value < 0:  # Left | ||||
|                         if current_col_idx > 0: | ||||
|                             next_card = current_row[current_col_idx - 1] | ||||
|                             next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                             if scroll_area: | ||||
|                                 scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                         else: | ||||
|                             if current_row_idx > 0: | ||||
|                                 prev_row = sorted_rows[current_row_idx - 1][1] | ||||
|                                 next_card = prev_row[-1] if prev_row else None | ||||
|                                 if next_card: | ||||
|                                     next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                                     if scroll_area: | ||||
|                                         scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                     elif value > 0:  # Right | ||||
|                         if current_col_idx < len(current_row) - 1: | ||||
|                             next_card = current_row[current_col_idx + 1] | ||||
|                             next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                             if scroll_area: | ||||
|                                 scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                         else: | ||||
|                             if current_row_idx < len(sorted_rows) - 1: | ||||
|                                 next_row = sorted_rows[current_row_idx + 1][1] | ||||
|                                 next_card = next_row[0] if next_row else None | ||||
|                                 if next_card: | ||||
|                                     next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                                     if scroll_area: | ||||
|                                         scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                 elif code == ecodes.ABS_HAT0Y and value != 0: | ||||
|                     if value > 0:  # Down | ||||
|                         if current_row_idx < len(sorted_rows) - 1: | ||||
|                             next_row = sorted_rows[current_row_idx + 1][1] | ||||
|                             current_x = focused.pos().x() + focused.width() / 2 | ||||
|                             next_card = min( | ||||
|                                 next_row, | ||||
|                                 key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x), | ||||
|                                 default=None | ||||
|                             ) | ||||
|                             if next_card: | ||||
|                                 next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                                 if scroll_area: | ||||
|                                     scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                     elif value < 0:  # Up | ||||
|                         if current_row_idx > 0: | ||||
|                             prev_row = sorted_rows[current_row_idx - 1][1] | ||||
|                             current_x = focused.pos().x() + focused.width() / 2 | ||||
|                             next_card = min( | ||||
|                                 prev_row, | ||||
|                                 key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x), | ||||
|                                 default=None | ||||
|                             ) | ||||
|                             if next_card: | ||||
|                                 next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                                 if scroll_area: | ||||
|                                     scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                         elif current_row_idx == 0: | ||||
|                             self._parent.tabButtons[0].setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|  | ||||
|             # Vertical navigation in other tabs | ||||
|             elif code == ecodes.ABS_HAT0Y and value != 0: | ||||
|             if code == ecodes.ABS_HAT0Y and value != 0: | ||||
|                 focused = QApplication.focusWidget() | ||||
|                 page = self._parent.stackedWidget.currentWidget() | ||||
|                 if value > 0:  # Down | ||||
| @@ -1336,8 +1359,8 @@ class InputManager(QObject): | ||||
|                     self.gamepad = None | ||||
|                     if self.gamepad_thread: | ||||
|                         self.gamepad_thread.join() | ||||
|                     # Signal to exit fullscreen mode | ||||
|                     self.toggle_fullscreen.emit(False) | ||||
|                     if read_auto_fullscreen_gamepad() and not read_fullscreen_config(): | ||||
|                         self.toggle_fullscreen.emit(False) | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error handling udev event: {e}", exc_info=True) | ||||
|  | ||||
|   | ||||
| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-10-09 16:37+0500\n" | ||||
| "POT-Creation-Date: 2025-10-12 15:20+0500\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language: de_DE\n" | ||||
| @@ -395,9 +395,6 @@ msgstr "" | ||||
| msgid "Auto Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -416,6 +413,21 @@ msgstr "" | ||||
| msgid "Search" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation already in progress." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start installation." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation completed successfully." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation failed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation error." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Loading Steam games..." | ||||
| msgstr "" | ||||
|  | ||||
| @@ -432,12 +444,6 @@ msgstr "" | ||||
| msgid "Added '{name}'" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Here you can configure automatic game installation..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "List of available emulators and their configuration..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Compatibility tool:" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -450,12 +456,6 @@ msgstr "" | ||||
| msgid "Registry Editor" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Control Panel" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Task Manager" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Command Prompt" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -477,6 +477,29 @@ msgstr "" | ||||
| msgid "Clear Prefix" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Launching tool..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Confirm Clear" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to clear prefix '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Prefix '{}' cleared successfully." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "" | ||||
| "Prefix '{}' cleared with errors:\n" | ||||
| "{}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start backup process." | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-10-09 16:37+0500\n" | ||||
| "POT-Creation-Date: 2025-10-12 15:20+0500\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language: es_ES\n" | ||||
| @@ -395,9 +395,6 @@ msgstr "" | ||||
| msgid "Auto Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -416,6 +413,21 @@ msgstr "" | ||||
| msgid "Search" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation already in progress." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start installation." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation completed successfully." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation failed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation error." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Loading Steam games..." | ||||
| msgstr "" | ||||
|  | ||||
| @@ -432,12 +444,6 @@ msgstr "" | ||||
| msgid "Added '{name}'" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Here you can configure automatic game installation..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "List of available emulators and their configuration..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Compatibility tool:" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -450,12 +456,6 @@ msgstr "" | ||||
| msgid "Registry Editor" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Control Panel" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Task Manager" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Command Prompt" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -477,6 +477,29 @@ msgstr "" | ||||
| msgid "Clear Prefix" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Launching tool..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Confirm Clear" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to clear prefix '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Prefix '{}' cleared successfully." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "" | ||||
| "Prefix '{}' cleared with errors:\n" | ||||
| "{}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start backup process." | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PortProtonQt 0.1.1\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-10-09 16:37+0500\n" | ||||
| "POT-Creation-Date: 2025-10-12 15:20+0500\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language-Team: LANGUAGE <LL@li.org>\n" | ||||
| @@ -393,9 +393,6 @@ msgstr "" | ||||
| msgid "Auto Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -414,6 +411,21 @@ msgstr "" | ||||
| msgid "Search" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation already in progress." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start installation." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation completed successfully." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation failed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation error." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Loading Steam games..." | ||||
| msgstr "" | ||||
|  | ||||
| @@ -430,12 +442,6 @@ msgstr "" | ||||
| msgid "Added '{name}'" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Here you can configure automatic game installation..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "List of available emulators and their configuration..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Compatibility tool:" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -448,12 +454,6 @@ msgstr "" | ||||
| msgid "Registry Editor" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Control Panel" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Task Manager" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Command Prompt" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -475,6 +475,29 @@ msgstr "" | ||||
| msgid "Clear Prefix" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Launching tool..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Confirm Clear" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to clear prefix '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Prefix '{}' cleared successfully." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "" | ||||
| "Prefix '{}' cleared with errors:\n" | ||||
| "{}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start backup process." | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
| @@ -9,8 +9,8 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-10-09 16:37+0500\n" | ||||
| "PO-Revision-Date: 2025-10-09 16:37+0500\n" | ||||
| "POT-Creation-Date: 2025-10-12 15:20+0500\n" | ||||
| "PO-Revision-Date: 2025-10-12 15:20+0500\n" | ||||
| "Last-Translator: \n" | ||||
| "Language: ru_RU\n" | ||||
| "Language-Team: ru_RU <LL@li.org>\n" | ||||
| @@ -402,9 +402,6 @@ msgstr "Библиотека" | ||||
| msgid "Auto Install" | ||||
| msgstr "Автоустановка" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "Эмуляторы" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "Настройки wine" | ||||
|  | ||||
| @@ -423,6 +420,21 @@ msgstr "Полный экран" | ||||
| msgid "Search" | ||||
| msgstr "Поиск" | ||||
|  | ||||
| msgid "Installation already in progress." | ||||
| msgstr "Установка уже выполняется." | ||||
|  | ||||
| msgid "Failed to start installation." | ||||
| msgstr "Не удалось запустить установку." | ||||
|  | ||||
| msgid "Installation completed successfully." | ||||
| msgstr "Установка завершена успешно." | ||||
|  | ||||
| msgid "Installation failed." | ||||
| msgstr "Установка не удалась." | ||||
|  | ||||
| msgid "Installation error." | ||||
| msgstr "Ошибка установки." | ||||
|  | ||||
| msgid "Loading Steam games..." | ||||
| msgstr "Загрузка игр из Steam..." | ||||
|  | ||||
| @@ -439,12 +451,6 @@ msgstr "Найти игры..." | ||||
| msgid "Added '{name}'" | ||||
| msgstr "'{name}' добавлен(а)" | ||||
|  | ||||
| msgid "Here you can configure automatic game installation..." | ||||
| msgstr "Здесь можно настроить автоматическую установку игр..." | ||||
|  | ||||
| msgid "List of available emulators and their configuration..." | ||||
| msgstr "Список доступных эмуляторов и их настройка..." | ||||
|  | ||||
| msgid "Compatibility tool:" | ||||
| msgstr "Инструмент совместимости:" | ||||
|  | ||||
| @@ -457,12 +463,6 @@ msgstr "Конфигурация Wine" | ||||
| msgid "Registry Editor" | ||||
| msgstr "Редактор реестра" | ||||
|  | ||||
| msgid "Control Panel" | ||||
| msgstr "Панель управления" | ||||
|  | ||||
| msgid "Task Manager" | ||||
| msgstr "Диспетчер задач" | ||||
|  | ||||
| msgid "Command Prompt" | ||||
| msgstr "Командная строка" | ||||
|  | ||||
| @@ -484,6 +484,31 @@ msgstr "Удалить Префикс" | ||||
| msgid "Clear Prefix" | ||||
| msgstr "Очистить Префикс" | ||||
|  | ||||
| msgid "Launching tool..." | ||||
| msgstr "Запуск инструмента..." | ||||
|  | ||||
| msgid "Failed to start process." | ||||
| msgstr "Не удалось запустить процесс." | ||||
|  | ||||
| msgid "Confirm Clear" | ||||
| msgstr "Подтвердите очистку" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to clear prefix '{}'?" | ||||
| msgstr "Вы уверены, что хотите очистить префикс «{}»?" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Prefix '{}' cleared successfully." | ||||
| msgstr "Префикс '{}' успешно удален." | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "" | ||||
| "Prefix '{}' cleared with errors:\n" | ||||
| "{}" | ||||
| msgstr "" | ||||
| "Префикс '{}' очищен с ошибками:\n" | ||||
| "{}" | ||||
|  | ||||
| msgid "Failed to start backup process." | ||||
| msgstr "Не удалось запустить процесс резервного копирования." | ||||
|  | ||||
|   | ||||
| @@ -5,12 +5,13 @@ import signal | ||||
| import subprocess | ||||
| import sys | ||||
| import psutil | ||||
| import re | ||||
|  | ||||
| from portprotonqt.logger import get_logger | ||||
| from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog | ||||
| from portprotonqt.game_card import GameCard | ||||
| from portprotonqt.animations import DetailPageAnimations | ||||
| from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel | ||||
| from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel, FlowLayout | ||||
| from portprotonqt.portproton_api import PortProtonAPI | ||||
| from portprotonqt.input_manager import InputManager | ||||
| from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit | ||||
| @@ -38,7 +39,7 @@ from portprotonqt.game_library_manager import GameLibraryManager | ||||
| from portprotonqt.virtual_keyboard import VirtualKeyboard | ||||
|  | ||||
| from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, | ||||
|                                QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout) | ||||
|                                QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout, QScrollArea, QScroller) | ||||
| from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot, QProcess | ||||
| from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices | ||||
| from typing import cast | ||||
| @@ -129,6 +130,11 @@ class MainWindow(QMainWindow): | ||||
|         self.update_progress.connect(self.progress_bar.setValue) | ||||
|         self.update_status_message.connect(self.statusBar().showMessage) | ||||
|  | ||||
|         self.installing = False | ||||
|         self.current_install_script = None | ||||
|         self.install_process = None | ||||
|         self.install_monitor_timer = None | ||||
|  | ||||
|         # Центральный виджет и основной layout | ||||
|         centralWidget = QWidget() | ||||
|         self.setCentralWidget(centralWidget) | ||||
| @@ -166,7 +172,6 @@ class MainWindow(QMainWindow): | ||||
|         tabs = [ | ||||
|             _("Library"), | ||||
|             _("Auto Install"), | ||||
|             _("Emulators"), | ||||
|             _("Wine Settings"), | ||||
|             _("PortProton Settings"), | ||||
|             _("Themes") | ||||
| @@ -198,7 +203,6 @@ class MainWindow(QMainWindow): | ||||
|  | ||||
|         self.createInstalledTab() | ||||
|         self.createAutoInstallTab() | ||||
|         self.createEmulatorsTab() | ||||
|         self.createWineTab() | ||||
|         self.createPortProtonTab() | ||||
|         self.createThemeTab() | ||||
| @@ -439,6 +443,102 @@ class MainWindow(QMainWindow): | ||||
|         # Update navigation buttons | ||||
|         self.updateNavButtons() | ||||
|  | ||||
|     def launch_autoinstall(self, script_name: str): | ||||
|         """Launch auto-install script.""" | ||||
|         if self.installing: | ||||
|             QMessageBox.warning(self, _("Warning"), _("Installation already in progress.")) | ||||
|             return | ||||
|         self.installing = True | ||||
|         self.current_install_script = script_name | ||||
|         self.seen_progress = False | ||||
|         self.current_percent = 0.0 | ||||
|         start_sh = os.path.join(self.portproton_location or "", "data", "scripts", "start.sh") if self.portproton_location else "" | ||||
|         if not os.path.exists(start_sh): | ||||
|             self.installing = False | ||||
|             return | ||||
|         cmd = [start_sh, "cli", "--autoinstall", script_name] | ||||
|         self.install_process = QProcess(self) | ||||
|         self.install_process.finished.connect(self.on_install_finished) | ||||
|         self.install_process.errorOccurred.connect(self.on_install_error) | ||||
|         self.install_process.start(cmd[0], cmd[1:]) | ||||
|         if not self.install_process.waitForStarted(5000): | ||||
|             self.installing = False | ||||
|             QMessageBox.warning(self, _("Error"), _("Failed to start installation.")) | ||||
|             return | ||||
|         self.progress_bar.setVisible(True) | ||||
|         self.progress_bar.setRange(0, 0)  # Indeterminate | ||||
|         self.update_status_message.emit(f"Processed {script_name} installation...", 0) | ||||
|         self.install_monitor_timer = QTimer(self) | ||||
|         self.install_monitor_timer.timeout.connect(self.monitor_install_progress) | ||||
|         self.install_monitor_timer.start(2000)  # Start monitoring after 2s | ||||
|  | ||||
|     def monitor_install_progress(self): | ||||
|         """Monitor /tmp/PortProton_$USER/process.log for progress.""" | ||||
|         user = os.getenv('USER', 'unknown') | ||||
|         log_file = f"/tmp/PortProton_{user}/process.log" | ||||
|         if not os.path.exists(log_file): | ||||
|             return | ||||
|         try: | ||||
|             with open(log_file, encoding='utf-8') as f: | ||||
|                 content = f.read() | ||||
|             # Extract all percentage matches, including .0% as 0.0 | ||||
|             matches = re.findall(r'([0-9]*\.?[0-9]+)%', content) | ||||
|             if matches: | ||||
|                 try: | ||||
|                     percent = float(matches[-1]) | ||||
|                     if percent > 0: | ||||
|                         self.seen_progress = True | ||||
|                         self.current_percent = percent | ||||
|                     elif self.seen_progress and percent == 0: | ||||
|                         self.current_percent = 100.0 | ||||
|                         if self.install_monitor_timer is not None: | ||||
|                             self.install_monitor_timer.stop() | ||||
|                     # Update progress bar to determinate if not already | ||||
|                     if self.progress_bar.maximum() == 0: | ||||
|                         self.progress_bar.setRange(0, 100) | ||||
|                         self.progress_bar.setFormat("%p")  # Show percentage | ||||
|                     self.progress_bar.setValue(int(self.current_percent)) | ||||
|                     if self.current_percent >= 100: | ||||
|                         if self.install_monitor_timer is not None: | ||||
|                             self.install_monitor_timer.stop() | ||||
|                 except ValueError: | ||||
|                     pass  # Ignore invalid floats | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error monitoring log: {e}") | ||||
|  | ||||
|     @Slot(int, int) | ||||
|     def on_install_finished(self, exit_code: int, exit_status: int): | ||||
|         """Handle installation finish.""" | ||||
|         self.installing = False | ||||
|         if self.install_monitor_timer is not None: | ||||
|             self.install_monitor_timer.stop() | ||||
|             self.install_monitor_timer.deleteLater() | ||||
|             self.install_monitor_timer = None | ||||
|         self.progress_bar.setRange(0, 100) | ||||
|         self.progress_bar.setValue(100) | ||||
|         if exit_code == 0: | ||||
|             self.update_status_message.emit(_("Installation completed successfully."), 5000) | ||||
|             QTimer.singleShot(500, lambda: self.restart_application()) | ||||
|         else: | ||||
|             self.update_status_message.emit(_("Installation failed."), 5000) | ||||
|             QMessageBox.warning(self, _("Error"), f"Installation failed (code: {exit_code}).") | ||||
|         self.progress_bar.setVisible(False) | ||||
|         self.current_install_script = None | ||||
|         if self.install_process: | ||||
|             self.install_process.deleteLater() | ||||
|             self.install_process = None | ||||
|  | ||||
|     def on_install_error(self, error: QProcess.ProcessError): | ||||
|         """Handle installation error.""" | ||||
|         self.installing = False | ||||
|         if self.install_monitor_timer is not None: | ||||
|             self.install_monitor_timer.stop() | ||||
|             self.install_monitor_timer.deleteLater() | ||||
|             self.install_monitor_timer = None | ||||
|         self.update_status_message.emit(_("Installation error."), 5000) | ||||
|         QMessageBox.warning(self, _("Error"), f"Process error: {error}") | ||||
|         self.progress_bar.setVisible(False) | ||||
|  | ||||
|     @Slot(list) | ||||
|     def on_games_loaded(self, games: list[tuple]): | ||||
|         self.game_library_manager.set_games(games) | ||||
| @@ -960,52 +1060,167 @@ class MainWindow(QMainWindow): | ||||
|                 get_steam_game_info_async(final_name, exec_line, on_steam_info) | ||||
|  | ||||
|     def createAutoInstallTab(self): | ||||
|         """Вкладка 'Auto Install'.""" | ||||
|         self.autoInstallWidget = QWidget() | ||||
|         self.autoInstallWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE) | ||||
|         self.autoInstallWidget.setObjectName("otherPage") | ||||
|         layout = QVBoxLayout(self.autoInstallWidget) | ||||
|         layout.setContentsMargins(10, 18, 10, 10) | ||||
|         autoInstallPage = QWidget() | ||||
|         autoInstallPage.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE) | ||||
|         autoInstallLayout = QVBoxLayout(autoInstallPage) | ||||
|         autoInstallLayout.setContentsMargins(0, 0, 0, 0) | ||||
|         autoInstallLayout.setSpacing(0) | ||||
|  | ||||
|         self.autoInstallTitle = QLabel(_("Auto Install")) | ||||
|         self.autoInstallTitle.setStyleSheet(self.theme.TAB_TITLE_STYLE) | ||||
|         self.autoInstallTitle.setObjectName("tabTitle") | ||||
|         layout.addWidget(self.autoInstallTitle) | ||||
|         # Верхняя панель с заголовком и поиском | ||||
|         headerWidget = QWidget() | ||||
|         headerLayout = QHBoxLayout(headerWidget) | ||||
|         headerLayout.setContentsMargins(20, 10, 20, 10) | ||||
|         headerLayout.setSpacing(10) | ||||
|  | ||||
|         self.autoInstallContent = QLabel(_("Here you can configure automatic game installation...")) | ||||
|         self.autoInstallContent.setStyleSheet(self.theme.CONTENT_STYLE) | ||||
|         self.autoInstallContent.setObjectName("tabContent") | ||||
|         layout.addWidget(self.autoInstallContent) | ||||
|         layout.addStretch(1) | ||||
|         # Заголовок | ||||
|         titleLabel = QLabel(_("Auto Install")) | ||||
|         titleLabel.setStyleSheet(self.theme.TAB_TITLE_STYLE) | ||||
|         titleLabel.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft) | ||||
|         headerLayout.addWidget(titleLabel) | ||||
|  | ||||
|         self.stackedWidget.addWidget(self.autoInstallWidget) | ||||
|         headerLayout.addStretch() | ||||
|  | ||||
|     def createEmulatorsTab(self): | ||||
|         """Вкладка 'Emulators'.""" | ||||
|         self.emulatorsWidget = QWidget() | ||||
|         self.emulatorsWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE) | ||||
|         self.emulatorsWidget.setObjectName("otherPage") | ||||
|         layout = QVBoxLayout(self.emulatorsWidget) | ||||
|         layout.setContentsMargins(10, 18, 10, 10) | ||||
|         # Поисковая строка | ||||
|         self.autoInstallSearchLineEdit = CustomLineEdit(self, theme=self.theme) | ||||
|         icon: QIcon = cast(QIcon, self.theme_manager.get_icon("search")) | ||||
|         action_pos = QLineEdit.ActionPosition.LeadingPosition | ||||
|         self.search_action = self.autoInstallSearchLineEdit.addAction(icon, action_pos) | ||||
|         self.autoInstallSearchLineEdit.setMaximumWidth(200) | ||||
|         self.autoInstallSearchLineEdit.setPlaceholderText(_("Find Games ...")) | ||||
|         self.autoInstallSearchLineEdit.setClearButtonEnabled(True) | ||||
|         self.autoInstallSearchLineEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE) | ||||
|         self.autoInstallSearchLineEdit.textChanged.connect(self.filterAutoInstallGames) | ||||
|         headerLayout.addWidget(self.autoInstallSearchLineEdit) | ||||
|  | ||||
|         self.emulatorsTitle = QLabel(_("Emulators")) | ||||
|         self.emulatorsTitle.setStyleSheet(self.theme.TAB_TITLE_STYLE) | ||||
|         self.emulatorsTitle.setObjectName("tabTitle") | ||||
|         layout.addWidget(self.emulatorsTitle) | ||||
|         autoInstallLayout.addWidget(headerWidget) | ||||
|  | ||||
|         self.emulatorsContent = QLabel(_("List of available emulators and their configuration...")) | ||||
|         self.emulatorsContent.setStyleSheet(self.theme.CONTENT_STYLE) | ||||
|         self.emulatorsContent.setObjectName("tabContent") | ||||
|         layout.addWidget(self.emulatorsContent) | ||||
|         layout.addStretch(1) | ||||
|         # Прогресс-бар | ||||
|         self.autoInstallProgress = QProgressBar() | ||||
|         self.autoInstallProgress.setStyleSheet(self.theme.PROGRESS_BAR_STYLE) | ||||
|         self.autoInstallProgress.setVisible(False) | ||||
|         autoInstallLayout.addWidget(self.autoInstallProgress) | ||||
|  | ||||
|         self.stackedWidget.addWidget(self.emulatorsWidget) | ||||
|         # Скролл | ||||
|         self.autoInstallScrollArea = QScrollArea() | ||||
|         self.autoInstallScrollArea.setWidgetResizable(True) | ||||
|         self.autoInstallScrollArea.setStyleSheet(self.theme.SCROLL_AREA_STYLE) | ||||
|         QScroller.grabGesture(self.autoInstallScrollArea.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture) | ||||
|  | ||||
|         self.autoInstallContainer = QWidget() | ||||
|         self.autoInstallContainerLayout = FlowLayout(self.autoInstallContainer) | ||||
|         self.autoInstallContainer.setLayout(self.autoInstallContainerLayout) | ||||
|         self.autoInstallScrollArea.setWidget(self.autoInstallContainer) | ||||
|  | ||||
|         autoInstallLayout.addWidget(self.autoInstallScrollArea) | ||||
|  | ||||
|         # Хранение карточек | ||||
|         self.autoInstallGameCards = {} | ||||
|         self.allAutoInstallCards = [] | ||||
|  | ||||
|         # Обновление обложки | ||||
|         def on_autoinstall_cover_updated(exe_name, local_path): | ||||
|             if exe_name in self.autoInstallGameCards and local_path: | ||||
|                 card = self.autoInstallGameCards[exe_name] | ||||
|                 card.cover_path = local_path | ||||
|                 load_pixmap_async(local_path, self.card_width, int(self.card_width * 1.5), card.on_cover_loaded) | ||||
|  | ||||
|         # Загрузка игр | ||||
|         def on_autoinstall_games_loaded(games: list[tuple]): | ||||
|             self.autoInstallProgress.setVisible(False) | ||||
|  | ||||
|             # Очистка | ||||
|             while self.autoInstallContainerLayout.count(): | ||||
|                 child = self.autoInstallContainerLayout.takeAt(0) | ||||
|                 if child: | ||||
|                     child.widget().deleteLater() | ||||
|  | ||||
|             self.autoInstallGameCards.clear() | ||||
|             self.allAutoInstallCards.clear() | ||||
|  | ||||
|             if not games: | ||||
|                 return | ||||
|  | ||||
|             # Callback для запуска установки | ||||
|             def select_callback(name, description, cover_path, appid, exec_line, controller_support, *_): | ||||
|                 if not exec_line or not exec_line.startswith("autoinstall:"): | ||||
|                     logger.warning(f"Invalid exec_line for autoinstall: {exec_line}") | ||||
|                     return | ||||
|                 script_name = exec_line[11:].lstrip(':').strip() | ||||
|                 self.launch_autoinstall(script_name) | ||||
|  | ||||
|             # Создаём карточки | ||||
|             for game_tuple in games: | ||||
|                 name, description, cover_path, appid, controller_support, exec_line, *_ , game_source, exe_name = game_tuple | ||||
|  | ||||
|                 card = GameCard( | ||||
|                     name, description, cover_path, appid, controller_support, | ||||
|                     exec_line, None, None, None, | ||||
|                     None, None, None, game_source, | ||||
|                     select_callback=select_callback, | ||||
|                     theme=self.theme, | ||||
|                     card_width=self.card_width, | ||||
|                     parent=self.autoInstallContainer, | ||||
|                 ) | ||||
|  | ||||
|                 # Hide badges and favorite button | ||||
|                 if hasattr(card, 'steamLabel'): | ||||
|                     card.steamLabel.setVisible(False) | ||||
|                 if hasattr(card, 'egsLabel'): | ||||
|                     card.egsLabel.setVisible(False) | ||||
|                 if hasattr(card, 'portprotonLabel'): | ||||
|                     card.portprotonLabel.setVisible(False) | ||||
|                 if hasattr(card, 'protondbLabel'): | ||||
|                     card.protondbLabel.setVisible(False) | ||||
|                 if hasattr(card, 'anticheatLabel'): | ||||
|                     card.anticheatLabel.setVisible(False) | ||||
|                 if hasattr(card, 'favoriteLabel'): | ||||
|                     card.favoriteLabel.setVisible(False) | ||||
|  | ||||
|                 self.autoInstallGameCards[exe_name] = card | ||||
|                 self.allAutoInstallCards.append(card) | ||||
|                 self.autoInstallContainerLayout.addWidget(card) | ||||
|  | ||||
|             # Загружаем недостающие обложки | ||||
|             for game_tuple in games: | ||||
|                 name, _, cover_path, *_ , game_source, exe_name = game_tuple | ||||
|                 if not cover_path: | ||||
|                     self.portproton_api.download_autoinstall_cover_async( | ||||
|                         exe_name, timeout=5, | ||||
|                         callback=lambda path, ex=exe_name: on_autoinstall_cover_updated(ex, path) | ||||
|                     ) | ||||
|  | ||||
|             self.autoInstallContainer.updateGeometry() | ||||
|             self.autoInstallScrollArea.updateGeometry() | ||||
|             self.filterAutoInstallGames() | ||||
|  | ||||
|         # Показываем прогресс | ||||
|         self.autoInstallProgress.setVisible(True) | ||||
|         self.autoInstallProgress.setRange(0, 0) | ||||
|         self.portproton_api.get_autoinstall_games_async(on_autoinstall_games_loaded) | ||||
|  | ||||
|         self.stackedWidget.addWidget(autoInstallPage) | ||||
|  | ||||
|     def filterAutoInstallGames(self): | ||||
|         """Filter auto install game cards based on search text.""" | ||||
|         search_text = self.autoInstallSearchLineEdit.text().lower().strip() | ||||
|         visible_count = 0 | ||||
|  | ||||
|         for card in self.allAutoInstallCards: | ||||
|             if search_text in card.name.lower(): | ||||
|                 card.setVisible(True) | ||||
|                 visible_count += 1 | ||||
|             else: | ||||
|                 card.setVisible(False) | ||||
|  | ||||
|         # Re-layout the container | ||||
|         self.autoInstallContainerLayout.invalidate() | ||||
|         self.autoInstallContainer.updateGeometry() | ||||
|         self.autoInstallScrollArea.updateGeometry() | ||||
|  | ||||
|     def createWineTab(self): | ||||
|         """Вкладка 'Wine Settings'.""" | ||||
|         self.wineWidget = QWidget() | ||||
|         self.wineWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE) | ||||
|         self.wineWidget.setObjectName("otherPage") | ||||
|         layout = QVBoxLayout(self.wineWidget) | ||||
|         layout.setContentsMargins(10, 18, 10, 10) | ||||
|  | ||||
| @@ -1061,21 +1276,20 @@ class MainWindow(QMainWindow): | ||||
|         tools_grid.setSpacing(6) | ||||
|  | ||||
|         tools = [ | ||||
|             ("winecfg", _("Wine Configuration")), | ||||
|             ("regedit", _("Registry Editor")), | ||||
|             ("control", _("Control Panel")), | ||||
|             ("taskmgr", _("Task Manager")), | ||||
|             ("explorer", _("File Explorer")), | ||||
|             ("cmd", _("Command Prompt")), | ||||
|             ("uninstaller", _("Uninstaller")), | ||||
|             ("--winecfg", _("Wine Configuration")), | ||||
|             ("--winereg", _("Registry Editor")), | ||||
|             ("--winefile", _("File Explorer")), | ||||
|             ("--winecmd", _("Command Prompt")), | ||||
|             ("--wine_uninstaller", _("Uninstaller")), | ||||
|         ] | ||||
|  | ||||
|         for i, (_tool_cmd, tool_name) in enumerate(tools): | ||||
|         for i, (tool_cmd, tool_name) in enumerate(tools): | ||||
|             row = i // 3 | ||||
|             col = i % 3 | ||||
|             btn = AutoSizeButton(tool_name, update_size=False) | ||||
|             btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) | ||||
|             btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus) | ||||
|             btn.clicked.connect(lambda checked, t=tool_cmd: self.launch_generic_tool(t)) | ||||
|             tools_grid.addWidget(btn, row, col) | ||||
|  | ||||
|         for col in range(3): | ||||
| @@ -1093,7 +1307,7 @@ class MainWindow(QMainWindow): | ||||
|             (_("Load Prefix Backup"), self.load_prefix_backup), | ||||
|             (_("Delete Compatibility Tool"), self.delete_compat_tool), | ||||
|             (_("Delete Prefix"), self.delete_prefix), | ||||
|             (_("Clear Prefix"), None), | ||||
|             (_("Clear Prefix"), self.clear_prefix), | ||||
|         ] | ||||
|  | ||||
|         for i, (text, callback) in enumerate(additional_buttons): | ||||
| @@ -1114,8 +1328,220 @@ class MainWindow(QMainWindow): | ||||
|         additional_grid.setContentsMargins(10, 6, 10, 0) | ||||
|         layout.addStretch(1) | ||||
|  | ||||
|         self.wine_progress_bar = QProgressBar(self.wineWidget) | ||||
|         self.wine_progress_bar.setStyleSheet(self.theme.PROGRESS_BAR_STYLE) | ||||
|         self.wine_progress_bar.setMaximumWidth(200) | ||||
|         self.wine_progress_bar.setTextVisible(True) | ||||
|         self.wine_progress_bar.setVisible(False) | ||||
|         self.wine_progress_bar.setRange(0, 0) | ||||
|  | ||||
|         wine_progress_layout = QHBoxLayout() | ||||
|         wine_progress_layout.addStretch(1) | ||||
|         wine_progress_layout.addWidget(self.wine_progress_bar) | ||||
|         layout.addLayout(wine_progress_layout) | ||||
|  | ||||
|         self.stackedWidget.addWidget(self.wineWidget) | ||||
|  | ||||
|     def launch_generic_tool(self, cli_arg): | ||||
|         wine = self.wineCombo.currentText() | ||||
|         prefix = self.prefixCombo.currentText() | ||||
|         if not wine or not prefix: | ||||
|             return | ||||
|         if not self.portproton_location: | ||||
|             return | ||||
|         start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh") | ||||
|         if not os.path.exists(start_sh): | ||||
|             return | ||||
|         cmd = [start_sh, "cli", cli_arg, wine, prefix] | ||||
|  | ||||
|         # Показываем прогресс-бар перед запуском | ||||
|         self.wine_progress_bar.setVisible(True) | ||||
|         self.update_status_message.emit(_("Launching tool..."), 0) | ||||
|  | ||||
|         proc = QProcess(self) | ||||
|         proc.finished.connect(lambda exitCode, exitStatus: self._on_wine_tool_finished(exitCode, cli_arg)) | ||||
|         proc.errorOccurred.connect(lambda error: self._on_wine_tool_error(error, cli_arg)) | ||||
|         proc.start(cmd[0], cmd[1:]) | ||||
|  | ||||
|         if not proc.waitForStarted(5000): | ||||
|             self.wine_progress_bar.setVisible(False) | ||||
|             self.update_status_message.emit("", 0) | ||||
|             QMessageBox.warning(self, _("Error"), _("Failed to start process.")) | ||||
|             return | ||||
|  | ||||
|         self._start_wine_process_monitor(cli_arg) | ||||
|  | ||||
|     def _start_wine_process_monitor(self, cli_arg): | ||||
|         """Запускает таймер для мониторинга запуска Wine утилиты.""" | ||||
|         self.wine_monitor_timer = QTimer(self) | ||||
|         self.wine_monitor_timer.setInterval(500) | ||||
|         self.wine_monitor_timer.timeout.connect(lambda: self._check_wine_process(cli_arg)) | ||||
|         self.wine_monitor_timer.start() | ||||
|  | ||||
|     def _check_wine_process(self, cli_arg): | ||||
|         """Проверяет, запустился ли целевой .exe процесс.""" | ||||
|         exe_map = { | ||||
|             "--winecfg": "winecfg.exe", | ||||
|             "--winereg": "regedit.exe", | ||||
|             "--winefile": "winefile.exe", | ||||
|             "--winecmd": "cmd.exe", | ||||
|             "--wine_uninstaller": "uninstaller.exe", | ||||
|         } | ||||
|         target_exe = exe_map.get(cli_arg, "") | ||||
|         if not target_exe: | ||||
|             return | ||||
|  | ||||
|         # Проверяем процессы через psutil | ||||
|         for proc in psutil.process_iter(attrs=["name"]): | ||||
|             if proc.info["name"].lower() == target_exe.lower(): | ||||
|                 # Процесс запустился — скрываем прогресс-бар и останавливаем мониторинг | ||||
|                 self.wine_progress_bar.setVisible(False) | ||||
|                 self.update_status_message.emit("", 0) | ||||
|                 if hasattr(self, 'wine_monitor_timer') and self.wine_monitor_timer is not None: | ||||
|                     self.wine_monitor_timer.stop() | ||||
|                     self.wine_monitor_timer.deleteLater() | ||||
|                     self.wine_monitor_timer = None | ||||
|                 logger.info(f"Wine tool {target_exe} started successfully") | ||||
|                 return | ||||
|  | ||||
|     def _on_wine_tool_finished(self, exitCode, cli_arg): | ||||
|         """Обработчик завершения Wine утилиты.""" | ||||
|         self.wine_progress_bar.setVisible(False) | ||||
|         self.update_status_message.emit("", 0) | ||||
|         # Останавливаем мониторинг, если он активен | ||||
|         if hasattr(self, 'wine_monitor_timer') and self.wine_monitor_timer is not None: | ||||
|             self.wine_monitor_timer.stop() | ||||
|             self.wine_monitor_timer.deleteLater() | ||||
|             self.wine_monitor_timer = None | ||||
|         if exitCode == 0: | ||||
|             logger.info(f"Wine tool {cli_arg} finished successfully") | ||||
|         else: | ||||
|             logger.warning(f"Wine tool {cli_arg} finished with exit code {exitCode}") | ||||
|  | ||||
|     def _on_wine_tool_error(self, error, cli_arg): | ||||
|         """Обработчик ошибки запуска Wine утилиты.""" | ||||
|         self.wine_progress_bar.setVisible(False) | ||||
|         self.update_status_message.emit("", 0) | ||||
|         # Останавливаем мониторинг, если он активен | ||||
|         if hasattr(self, 'wine_monitor_timer') and self.wine_monitor_timer is not None: | ||||
|             self.wine_monitor_timer.stop() | ||||
|             self.wine_monitor_timer.deleteLater() | ||||
|             self.wine_monitor_timer = None | ||||
|         logger.error(f"Wine tool {cli_arg} error: {error}") | ||||
|         QMessageBox.warning(self, _("Error"), f"Failed to launch tool: {error}") | ||||
|  | ||||
|     def clear_prefix(self): | ||||
|         """Очистка префикса (позже удалить).""" | ||||
|         selected_prefix = self.prefixCombo.currentText() | ||||
|         selected_wine = self.wineCombo.currentText() | ||||
|         if not selected_prefix or not selected_wine: | ||||
|             return | ||||
|         if not self.portproton_location: | ||||
|             return | ||||
|  | ||||
|         reply = QMessageBox.question( | ||||
|             self, | ||||
|             _("Confirm Clear"), | ||||
|             _("Are you sure you want to clear prefix '{}'?").format(selected_prefix), | ||||
|             QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, | ||||
|             QMessageBox.StandardButton.No | ||||
|         ) | ||||
|         if reply != QMessageBox.StandardButton.Yes: | ||||
|             return | ||||
|  | ||||
|         prefix_dir = os.path.join(self.portproton_location, "data", "prefixes", selected_prefix) | ||||
|         if not os.path.exists(prefix_dir): | ||||
|             return | ||||
|  | ||||
|         success = True | ||||
|         errors = [] | ||||
|  | ||||
|         # Удаление файлов | ||||
|         files_to_remove = [ | ||||
|             os.path.join(prefix_dir, "*.dot*"), | ||||
|             os.path.join(prefix_dir, "*.prog*"), | ||||
|             os.path.join(prefix_dir, ".wine_ver"), | ||||
|             os.path.join(prefix_dir, "system.reg"), | ||||
|             os.path.join(prefix_dir, "user.reg"), | ||||
|             os.path.join(prefix_dir, "userdef.reg"), | ||||
|             os.path.join(prefix_dir, "winetricks.log"), | ||||
|             os.path.join(prefix_dir, ".update-timestamp"), | ||||
|             os.path.join(prefix_dir, "drive_c", ".windows-serial"), | ||||
|         ] | ||||
|  | ||||
|         import glob | ||||
|         for pattern in files_to_remove: | ||||
|             if "*" in pattern:  # Глобальный паттерн | ||||
|                 matches = glob.glob(pattern) | ||||
|                 for file_path in matches: | ||||
|                     try: | ||||
|                         if os.path.exists(file_path): | ||||
|                             os.remove(file_path) | ||||
|                     except Exception as e: | ||||
|                         success = False | ||||
|                         errors.append(str(e)) | ||||
|             else:  # Конкретный файл | ||||
|                 try: | ||||
|                     if os.path.exists(pattern): | ||||
|                         os.remove(pattern) | ||||
|                 except Exception as e: | ||||
|                     success = False | ||||
|                     errors.append(str(e)) | ||||
|  | ||||
|         # Удаление директорий | ||||
|         dirs_to_remove = [ | ||||
|             os.path.join(prefix_dir, "drive_c", "windows"), | ||||
|             os.path.join(prefix_dir, "drive_c", "ProgramData", "Setup"), | ||||
|             os.path.join(prefix_dir, "drive_c", "ProgramData", "Windows"), | ||||
|             os.path.join(prefix_dir, "drive_c", "ProgramData", "WindowsTask"), | ||||
|             os.path.join(prefix_dir, "drive_c", "ProgramData", "Package Cache"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Application Data", "Microsoft"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Application Data", "Temp"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Temporary Internet Files"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "Public", "Application Data", "Microsoft"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "Public", "Application Data", "wine_gecko"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "Public", "Temp"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Application Data", "Microsoft"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Application Data", "Temp"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Temporary Internet Files"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Application Data", "Microsoft"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Application Data", "wine_gecko"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Temp"), | ||||
|             os.path.join(prefix_dir, "drive_c", "Program Files", "Internet Explorer"), | ||||
|             os.path.join(prefix_dir, "drive_c", "Program Files", "Windows Media Player"), | ||||
|             os.path.join(prefix_dir, "drive_c", "Program Files", "Windows NT"), | ||||
|             os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Internet Explorer"), | ||||
|             os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Windows Media Player"), | ||||
|             os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Windows NT"), | ||||
|         ] | ||||
|  | ||||
|         import shutil | ||||
|         for dir_path in dirs_to_remove: | ||||
|             try: | ||||
|                 if os.path.exists(dir_path): | ||||
|                     shutil.rmtree(dir_path) | ||||
|             except Exception as e: | ||||
|                 success = False | ||||
|                 errors.append(str(e)) | ||||
|  | ||||
|         tmp_path = os.path.join(self.portproton_location, "data", "tmp") | ||||
|         if os.path.exists(tmp_path): | ||||
|             import glob | ||||
|             bin_files = glob.glob(os.path.join(tmp_path, "*.bin")) | ||||
|             foz_files = glob.glob(os.path.join(tmp_path, "*.foz")) | ||||
|             for file_path in bin_files + foz_files: | ||||
|                 try: | ||||
|                     os.remove(file_path) | ||||
|                 except Exception as e: | ||||
|                     success = False | ||||
|                     errors.append(str(e)) | ||||
|  | ||||
|         if success: | ||||
|             QMessageBox.information(self, _("Success"), _("Prefix '{}' cleared successfully.").format(selected_prefix)) | ||||
|         else: | ||||
|             error_msg = _("Prefix '{}' cleared with errors:\n{}").format(selected_prefix, "\n".join(errors[:5])) | ||||
|             QMessageBox.warning(self, _("Warning"), error_msg) | ||||
|  | ||||
|     def create_prefix_backup(self): | ||||
|         selected_prefix = self.prefixCombo.currentText() | ||||
|         if not selected_prefix: | ||||
| @@ -1196,8 +1622,9 @@ class MainWindow(QMainWindow): | ||||
|                 QMessageBox.information(self, _("Success"), _("Prefix '{}' deleted.").format(selected_prefix)) | ||||
|                 # обновляем список | ||||
|                 self.prefixCombo.clear() | ||||
|                 self.prefixes = [d for d in os.listdir(os.path.join(self.portproton_location, "data", "prefixes")) | ||||
|                                  if os.path.isdir(os.path.join(self.portproton_location, "data", "prefixes", d))] | ||||
|                 prefixes_path = os.path.join(self.portproton_location, "data", "prefixes") | ||||
|                 self.prefixes = [d for d in os.listdir(prefixes_path) | ||||
|                                 if os.path.isdir(os.path.join(prefixes_path, d))] | ||||
|                 self.prefixCombo.addItems(self.prefixes) | ||||
|             except Exception as e: | ||||
|                 QMessageBox.warning(self, _("Error"), _("Failed to delete prefix: {}").format(str(e))) | ||||
| @@ -1407,7 +1834,7 @@ class MainWindow(QMainWindow): | ||||
|         # # 8. Legendary Authentication | ||||
|         # self.legendaryAuthButton = AutoSizeButton( | ||||
|         #     _("Open Legendary Login"), | ||||
|         #     icon=self.theme_manager.get_icon("login") | ||||
|         #     icon=self.theme_manager.get_icon("login")self.theme_manager.get_icon("login") | ||||
|         # ) | ||||
|         # self.legendaryAuthButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) | ||||
|         # self.legendaryAuthButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) | ||||
| @@ -1600,9 +2027,6 @@ class MainWindow(QMainWindow): | ||||
|         gamepad_connected = self.input_manager.find_gamepad() is not None | ||||
|         if fullscreen or (auto_fullscreen_gamepad and gamepad_connected): | ||||
|             self.showFullScreen() | ||||
|         else: | ||||
|             self.showNormal() | ||||
|             self.resize(*read_window_geometry()) | ||||
|  | ||||
|         self.statusBar().showMessage(_("Settings saved"), 3000) | ||||
|  | ||||
| @@ -2392,9 +2816,7 @@ class MainWindow(QMainWindow): | ||||
|             else: | ||||
|                 # Запускаем игру через PortProton | ||||
|                 env_vars = os.environ.copy() | ||||
|                 env_vars['START_FROM_STEAM'] = '1' | ||||
|                 env_vars['LEGENDARY_CONFIG_PATH'] = self.legendary_config_path | ||||
|                 env_vars['PROCESS_LOG'] = '1' | ||||
|  | ||||
|                 wrapper = "flatpak run ru.linux_gaming.PortProton" | ||||
|                 if self.portproton_location is not None and ".var" not in self.portproton_location: | ||||
| @@ -2549,16 +2971,20 @@ class MainWindow(QMainWindow): | ||||
|             self.game_processes = []  # Очищаем список процессов | ||||
|  | ||||
|             # Очищаем таймеры | ||||
|             if hasattr(self, 'games_load_timer') and self.games_load_timer.isActive(): | ||||
|             if hasattr(self, 'games_load_timer') and self.games_load_timer is not None and self.games_load_timer.isActive(): | ||||
|                 self.games_load_timer.stop() | ||||
|             if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer.isActive(): | ||||
|             if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer is not None and self.settingsDebounceTimer.isActive(): | ||||
|                 self.settingsDebounceTimer.stop() | ||||
|             if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer.isActive(): | ||||
|             if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer is not None and self.searchDebounceTimer.isActive(): | ||||
|                 self.searchDebounceTimer.stop() | ||||
|             if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None and self.checkProcessTimer.isActive(): | ||||
|                 self.checkProcessTimer.stop() | ||||
|                 self.checkProcessTimer.deleteLater() | ||||
|                 self.checkProcessTimer = None | ||||
|             if hasattr(self, 'wine_monitor_timer') and self.wine_monitor_timer is not None: | ||||
|                 self.wine_monitor_timer.stop() | ||||
|                 self.wine_monitor_timer.deleteLater() | ||||
|                 self.wine_monitor_timer = None | ||||
|  | ||||
|             # Сохраняем настройки окна | ||||
|             if not read_fullscreen_config(): | ||||
|   | ||||
| @@ -4,9 +4,12 @@ import orjson | ||||
| import requests | ||||
| import urllib.parse | ||||
| import time | ||||
| import glob | ||||
| import re | ||||
| from collections.abc import Callable | ||||
| from portprotonqt.downloader import Downloader | ||||
| from portprotonqt.logger import get_logger | ||||
| from portprotonqt.config_utils import get_portproton_location | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
| CACHE_DURATION = 30 * 24 * 60 * 60  # 30 days in seconds | ||||
| @@ -52,6 +55,9 @@ class PortProtonAPI: | ||||
|         self.xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")) | ||||
|         self.custom_data_dir = os.path.join(self.xdg_data_home, "PortProtonQt", "custom_data") | ||||
|         os.makedirs(self.custom_data_dir, exist_ok=True) | ||||
|         self.portproton_location = get_portproton_location() | ||||
|         self.repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | ||||
|         self.builtin_custom_folder = os.path.join(self.repo_root, "custom_data") | ||||
|         self._topics_data = None | ||||
|  | ||||
|     def _get_game_dir(self, exe_name: str) -> str: | ||||
| @@ -68,40 +74,6 @@ class PortProtonAPI: | ||||
|             logger.debug(f"Failed to check file at {url}: {e}") | ||||
|             return False | ||||
|  | ||||
|     def download_game_assets(self, exe_name: str, timeout: int = 5) -> dict[str, str | None]: | ||||
|         game_dir = self._get_game_dir(exe_name) | ||||
|         results: dict[str, str | None] = {"cover": None, "metadata": None} | ||||
|         cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"] | ||||
|         cover_url_base = f"{self.base_url}/{exe_name}/cover" | ||||
|         metadata_url = f"{self.base_url}/{exe_name}/metadata.txt" | ||||
|  | ||||
|         for ext in cover_extensions: | ||||
|             cover_url = f"{cover_url_base}{ext}" | ||||
|             if self._check_file_exists(cover_url, timeout): | ||||
|                 local_cover_path = os.path.join(game_dir, f"cover{ext}") | ||||
|                 result = self.downloader.download(cover_url, local_cover_path, timeout=timeout) | ||||
|                 if result: | ||||
|                     results["cover"] = result | ||||
|                     logger.info(f"Downloaded cover for {exe_name} to {result}") | ||||
|                     break | ||||
|                 else: | ||||
|                     logger.error(f"Failed to download cover for {exe_name} from {cover_url}") | ||||
|             else: | ||||
|                 logger.debug(f"No cover found for {exe_name} with extension {ext}") | ||||
|  | ||||
|         if self._check_file_exists(metadata_url, timeout): | ||||
|             local_metadata_path = os.path.join(game_dir, "metadata.txt") | ||||
|             result = self.downloader.download(metadata_url, local_metadata_path, timeout=timeout) | ||||
|             if result: | ||||
|                 results["metadata"] = result | ||||
|                 logger.info(f"Downloaded metadata for {exe_name} to {result}") | ||||
|             else: | ||||
|                 logger.error(f"Failed to download metadata for {exe_name} from {metadata_url}") | ||||
|         else: | ||||
|             logger.debug(f"No metadata found for {exe_name}") | ||||
|  | ||||
|         return results | ||||
|  | ||||
|     def download_game_assets_async(self, exe_name: str, timeout: int = 5, callback: Callable[[dict[str, str | None]], None] | None = None) -> None: | ||||
|         game_dir = self._get_game_dir(exe_name) | ||||
|         cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"] | ||||
| @@ -163,6 +135,164 @@ class PortProtonAPI: | ||||
|             if callback: | ||||
|                 callback(results) | ||||
|  | ||||
|     def download_autoinstall_cover_async(self, exe_name: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None) -> None: | ||||
|         """Download only autoinstall cover image (PNG only, no metadata).""" | ||||
|         xdg_data_home = os.getenv("XDG_DATA_HOME", | ||||
|                                 os.path.join(os.path.expanduser("~"), ".local", "share")) | ||||
|         autoinstall_root = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall") | ||||
|         user_game_folder = os.path.join(autoinstall_root, exe_name) | ||||
|  | ||||
|         if not os.path.isdir(user_game_folder): | ||||
|             try: | ||||
|                 os.mkdir(user_game_folder) | ||||
|             except FileExistsError: | ||||
|                 pass | ||||
|  | ||||
|         cover_url = f"{self.base_url}/{exe_name}/cover.png" | ||||
|         local_cover_path = os.path.join(user_game_folder, "cover.png") | ||||
|  | ||||
|         def on_cover_downloaded(local_path: str | None): | ||||
|             if local_path: | ||||
|                 logger.info(f"Async autoinstall cover downloaded for {exe_name}: {local_path}") | ||||
|             else: | ||||
|                 logger.debug(f"No autoinstall cover downloaded for {exe_name}") | ||||
|             if callback: | ||||
|                 callback(local_path) | ||||
|  | ||||
|         if self._check_file_exists(cover_url, timeout): | ||||
|             self.downloader.download_async( | ||||
|                 cover_url, | ||||
|                 local_cover_path, | ||||
|                 timeout=timeout, | ||||
|                 callback=on_cover_downloaded | ||||
|             ) | ||||
|         else: | ||||
|             logger.debug(f"No autoinstall cover found for {exe_name}") | ||||
|             if callback: | ||||
|                 callback(None) | ||||
|  | ||||
|     def parse_autoinstall_script(self, file_path: str) -> tuple[str | None, str | None]: | ||||
|         """Extract display_name from # name comment and exe_name from autoinstall bash script.""" | ||||
|         try: | ||||
|             with open(file_path, encoding='utf-8') as f: | ||||
|                 content = f.read() | ||||
|  | ||||
|             # Skip emulators | ||||
|             if re.search(r'#\s*type\s*:\s*emulators', content, re.IGNORECASE): | ||||
|                 return None, None | ||||
|  | ||||
|             display_name = None | ||||
|             exe_name = None | ||||
|  | ||||
|             # Extract display_name from "# name:" comment | ||||
|             name_match = re.search(r'#\s*name\s*:\s*(.+)', content, re.IGNORECASE) | ||||
|             if name_match: | ||||
|                 display_name = name_match.group(1).strip() | ||||
|  | ||||
|             # --- pw_create_unique_exe --- | ||||
|             pw_match = re.search(r'pw_create_unique_exe(?:\s+["\']([^"\']+)["\'])?', content) | ||||
|             if pw_match: | ||||
|                 arg = pw_match.group(1) | ||||
|                 if arg: | ||||
|                     exe_name = arg.strip() | ||||
|                     if not exe_name.lower().endswith(".exe"): | ||||
|                         exe_name += ".exe" | ||||
|                 else: | ||||
|                     export_match = re.search( | ||||
|                         r'export\s+PORTWINE_CREATE_SHORTCUT_NAME\s*=\s*["\']([^"\']+)["\']', | ||||
|                         content, re.IGNORECASE) | ||||
|                     if export_match: | ||||
|                         exe_name = f"{export_match.group(1).strip()}.exe" | ||||
|  | ||||
|             else: | ||||
|                 portwine_match = None | ||||
|                 for line in content.splitlines(): | ||||
|                     stripped = line.strip() | ||||
|                     if stripped.startswith("#"): | ||||
|                         continue | ||||
|                     if "portwine_exe" in stripped and "=" in stripped: | ||||
|                         portwine_match = stripped | ||||
|                         break | ||||
|  | ||||
|                 if portwine_match: | ||||
|                     exe_expr = portwine_match.split("=", 1)[1].strip().strip("'\" ") | ||||
|                     exe_candidates = re.findall(r'[-\w\s/\\\.]+\.exe', exe_expr) | ||||
|                     if exe_candidates: | ||||
|                         exe_name = os.path.basename(exe_candidates[-1].strip()) | ||||
|  | ||||
|  | ||||
|             # Fallback | ||||
|             if not display_name and exe_name: | ||||
|                 display_name = exe_name | ||||
|  | ||||
|             return display_name, exe_name | ||||
|  | ||||
|         except Exception as e: | ||||
|             logger.error(f"Failed to parse {file_path}: {e}") | ||||
|             return None, None | ||||
|  | ||||
|     def get_autoinstall_games_async(self, callback: Callable[[list[tuple]], None]) -> None: | ||||
|         """Load auto-install games with user/builtin covers (no async download here).""" | ||||
|         games = [] | ||||
|         auto_dir = os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall") if self.portproton_location else "" | ||||
|         if not os.path.exists(auto_dir): | ||||
|             callback(games) | ||||
|             return | ||||
|  | ||||
|         scripts = sorted(glob.glob(os.path.join(auto_dir, "*"))) | ||||
|         if not scripts: | ||||
|             callback(games) | ||||
|             return | ||||
|  | ||||
|         xdg_data_home = os.getenv("XDG_DATA_HOME", | ||||
|                                 os.path.join(os.path.expanduser("~"), ".local", "share")) | ||||
|         base_autoinstall_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall") | ||||
|         os.makedirs(base_autoinstall_dir, exist_ok=True) | ||||
|  | ||||
|         for script_path in scripts: | ||||
|             display_name, exe_name = self.parse_autoinstall_script(script_path) | ||||
|             script_name = os.path.splitext(os.path.basename(script_path))[0] | ||||
|  | ||||
|             if not (display_name and exe_name): | ||||
|                 continue | ||||
|  | ||||
|             exe_name = os.path.splitext(exe_name)[0]  # Без .exe | ||||
|             user_game_folder = os.path.join(base_autoinstall_dir, exe_name) | ||||
|             os.makedirs(user_game_folder, exist_ok=True) | ||||
|  | ||||
|             # Поиск обложки | ||||
|             cover_path = "" | ||||
|             user_files = set(os.listdir(user_game_folder)) if os.path.exists(user_game_folder) else set() | ||||
|             for ext in [".jpg", ".png", ".jpeg", ".bmp"]: | ||||
|                 candidate = f"cover{ext}" | ||||
|                 if candidate in user_files: | ||||
|                     cover_path = os.path.join(user_game_folder, candidate) | ||||
|                     break | ||||
|  | ||||
|             if not cover_path: | ||||
|                 logger.debug(f"No local cover found for autoinstall {exe_name}") | ||||
|  | ||||
|             # Формируем кортеж игры (добавлен exe_name в конец) | ||||
|             game_tuple = ( | ||||
|                 display_name,  # name | ||||
|                 "",  # description | ||||
|                 cover_path,  # cover | ||||
|                 "",  # appid | ||||
|                 f"autoinstall:{script_name}",  # exec_line | ||||
|                 "",  # controller_support | ||||
|                 "Never",  # last_launch | ||||
|                 "0h 0m",  # formatted_playtime | ||||
|                 "",  # protondb_tier | ||||
|                 "",  # anticheat_status | ||||
|                 0,  # last_played | ||||
|                 0,  # playtime_seconds | ||||
|                 "autoinstall",  # game_source | ||||
|                 exe_name  # exe_name | ||||
|             ) | ||||
|             games.append(game_tuple) | ||||
|  | ||||
|         callback(games) | ||||
|  | ||||
|     def _load_topics_data(self): | ||||
|         """Load and cache linux_gaming_topics_min.json from the archive.""" | ||||
|         if self._topics_data is not None: | ||||
|   | ||||