diff --git a/portprotonqt/game_library_manager.py b/portprotonqt/game_library_manager.py index 5de1748..3668f92 100644 --- a/portprotonqt/game_library_manager.py +++ b/portprotonqt/game_library_manager.py @@ -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 diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py index 9a44f34..9db9f3d 100644 --- a/portprotonqt/main_window.py +++ b/portprotonqt/main_window.py @@ -5,6 +5,7 @@ import signal import subprocess import sys import psutil +import re from portprotonqt.logger import get_logger from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog @@ -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) 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) @@ -437,6 +443,103 @@ 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, "data", "scripts", "start.sh") + if not os.path.exists(start_sh): + self.installing = False + QMessageBox.warning(self, _("Error"), _("start.sh not found.")) + return + cmd = [start_sh, "cli", "--autoinstall", script_name] + print(cmd) + 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 + 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: + 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: + 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) + # Reload library after delay + QTimer.singleShot(3000, self.loadGames) + 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: + 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) @@ -958,25 +1061,113 @@ 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) + """Create the Auto Install tab with flow layout of simple game cards (cover, name, install button).""" + from portprotonqt.localization import _ + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(20) - self.autoInstallTitle = QLabel(_("Auto Install")) - self.autoInstallTitle.setStyleSheet(self.theme.TAB_TITLE_STYLE) - self.autoInstallTitle.setObjectName("tabTitle") - layout.addWidget(self.autoInstallTitle) + # Header label + header = QLabel(_("Auto Install Games")) + header.setStyleSheet(self.theme.DETAIL_PAGE_TITLE_STYLE) + layout.addWidget(header) - 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) + # Scroll area for games + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll_widget = QWidget() + from portprotonqt.custom_widgets import FlowLayout + self.auto_install_flow_layout = FlowLayout(scroll_widget) # Store reference for potential updates + self.auto_install_flow_layout.setSpacing(15) + self.auto_install_flow_layout.setContentsMargins(0, 0, 0, 0) - self.stackedWidget.addWidget(self.autoInstallWidget) + # Load games asynchronously (though now sync inside, but callback for consistency) + def on_autoinstall_games_loaded(games: list[tuple]): + # Clear existing widgets + while self.auto_install_flow_layout.count(): + child = self.auto_install_flow_layout.takeAt(0) + if child.widget(): + child.widget().deleteLater() + + for game in games: + name = game[0] + description = game[1] + cover_path = game[2] + exec_line = game[4] + script_name = exec_line.split("autoinstall:")[1] if exec_line.startswith("autoinstall:") else "" + + # Create simple card frame + card_frame = QFrame() + card_frame.setFixedWidth(self.card_width) + card_frame.setStyleSheet(self.theme.GAME_CARD_STYLE if hasattr(self.theme, 'GAME_CARD_STYLE') else "") + card_layout = QVBoxLayout(card_frame) + card_layout.setContentsMargins(10, 10, 10, 10) + card_layout.setSpacing(10) + + # Cover image + cover_label = QLabel() + cover_label.setFixedHeight(120) + cover_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + pixmap = QPixmap() + if cover_path and os.path.exists(cover_path) and pixmap.load(cover_path): + scaled_pix = pixmap.scaled(self.card_width - 40, 120, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + cover_label.setPixmap(scaled_pix) + else: + # Placeholder + placeholder_icon = self.theme_manager.get_theme_image("placeholder", self.current_theme_name) + if placeholder_icon: + pixmap.load(str(placeholder_icon)) + scaled_pix = pixmap.scaled(100, 100, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + cover_label.setPixmap(scaled_pix) + card_layout.addWidget(cover_label) + + # Name label + name_label = QLabel(name) + name_label.setWordWrap(True) + name_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + name_label.setStyleSheet(self.theme.CARD_TITLE_STYLE if hasattr(self.theme, 'CARD_TITLE_STYLE') else "") + card_layout.addWidget(name_label) + + # Optional short description + if description: + desc_label = QLabel(description[:100] + "..." if len(description) > 100 else description) + desc_label.setWordWrap(True) + desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + desc_label.setStyleSheet(self.theme.CARD_DESC_STYLE if hasattr(self.theme, 'CARD_DESC_STYLE') else "") + card_layout.addWidget(desc_label) + + # Install button + install_btn = AutoSizeButton(_("Install"), icon=self.theme_manager.get_icon("install")) + install_btn.setStyleSheet(self.theme.PLAY_BUTTON_STYLE) + install_btn.clicked.connect(lambda checked, s=script_name: self.launch_autoinstall(s)) + card_layout.addWidget(install_btn) + + card_layout.addStretch() + + # Add to flow layout + self.auto_install_flow_layout.addWidget(card_frame) + + scroll.setWidget(scroll_widget) + layout.addWidget(scroll) + + # Trigger load + self.portproton_api.get_autoinstall_games_async(on_autoinstall_games_loaded) + + self.stackedWidget.addWidget(tab) + + + def on_auto_install_search_changed(self, text: str): + """Filter auto-install games based on search text.""" + filtered_games = [g for g in self.auto_install_games if text.lower() in g[0].lower() or text.lower() in g[1].lower()] + self.populate_auto_install_grid(filtered_games) + self.auto_install_clear_search_button.setVisible(bool(text)) + + def clear_auto_install_search(self): + """Clear the auto-install search and repopulate grid.""" + self.auto_install_search_line.clear() + self.populate_auto_install_grid(self.auto_install_games) def createWineTab(self): """Вкладка 'Wine Settings'.""" @@ -2522,6 +2713,11 @@ class MainWindow(QMainWindow): QDesktopServices.openUrl(url) return + if exec_line.startswith("autoinstall:"): + script_name = exec_line.split("autoinstall:")[1] + self.launch_autoinstall(script_name) + return + # Обработка EGS-игр if exec_line.startswith("legendary:launch:"): app_name = exec_line.split("legendary:launch:")[1] diff --git a/portprotonqt/portproton_api.py b/portprotonqt/portproton_api.py index a3517b3..ed78f79 100644 --- a/portprotonqt/portproton_api.py +++ b/portprotonqt/portproton_api.py @@ -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,100 @@ class PortProtonAPI: if callback: callback(results) + 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 "# type: emulators" in content: + return None, None + + display_name = None + # Extract display_name from # name: comment + name_match = re.search(r'#\s*name\s*:\s*(.+)', content, re.MULTILINE | re.IGNORECASE) + if name_match: + display_name = name_match.group(1).strip() + + # Extract exe_name: prefer pw_create_unique_exe argument, then PORTWINE_CREATE_SHORTCUT_NAME, then portwine_exe basename + exe_name = None + + # Check for pw_create_unique_exe with argument + arg_match = re.search(r'pw_create_unique_exe\s+["\']([^"\']+)["\']', content, re.MULTILINE) + if arg_match: + exe_name = arg_match.group(1).strip() + + # Fallback to PORTWINE_CREATE_SHORTCUT_NAME + if not exe_name: + export_match = re.search(r'export\s+PORTWINE_CREATE_SHORTCUT_NAME\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE) + if export_match: + exe_name = export_match.group(1).strip() + + # Fallback to portwine_exe basename + if not exe_name: + portwine_match = re.search(r'portwine_exe\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE) + if portwine_match: + exe_path = portwine_match.group(1).strip() + exe_name = os.path.splitext(os.path.basename(exe_path))[0] + + # Fallback display_name to exe_name if not found + if not display_name and exe_name: + display_name = exe_name + + print(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 custom_data assets (cover and metadata).""" + games = [] + auto_dir = os.path.join(self.portproton_location, "data", "scripts", "pw_autoinstall") + 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 + + for script_path in scripts: + display_name, exe_name = self.parse_autoinstall_script(script_path) + if display_name and exe_name: + # Download assets + cover_path = "" + metadata_path = "" + description = "" + if metadata_path and os.path.exists(metadata_path): + try: + with open(metadata_path, encoding="utf-8") as f: + description = f.read().strip() + except Exception as e: + logger.error(f"Failed to read metadata for {exe_name}: {e}") + script_name = os.path.splitext(os.path.basename(script_path))[0] + # Basic tuple with assets + game_tuple = ( + display_name, # name + description, # 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 + ) + 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: