feat: initial add of autoinstall tab
Some checks failed
Code check / Check code (push) Failing after 4m6s

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
2025-10-11 19:19:47 +05:00
parent 1cf332cd87
commit bb617708ac
3 changed files with 321 additions and 51 deletions

View File

@@ -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

View File

@@ -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]

View File

@@ -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: