""" InstallModlistScreen for Jackify GUI """ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QApplication, QCheckBox, QStyledItemDelegate, QStyle, QTableWidget, QTableWidgetItem, QHeaderView, QMainWindow from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject, QUrl from PySide6.QtGui import QPixmap, QTextCursor, QColor, QPainter, QFont from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS from ..utils import ansi_to_html, set_responsive_minimum from ..widgets.unsupported_game_dialog import UnsupportedGameDialog import os import subprocess import sys import threading from typing import Optional from jackify.backend.handlers.shortcut_handler import ShortcutHandler from jackify.backend.handlers.wabbajack_parser import WabbajackParser import traceback from jackify.backend.core.modlist_operations import get_jackify_engine_path import signal import re import time from jackify.backend.handlers.subprocess_utils import ProcessManager from jackify.backend.handlers.config_handler import ConfigHandler from ..dialogs import SuccessDialog from jackify.backend.handlers.validation_handler import ValidationHandler from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog from jackify.frontends.gui.services.message_service import MessageService from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator # R&D: Progress reporting components from jackify.backend.handlers.progress_parser import ProgressStateManager from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator from jackify.frontends.gui.widgets.file_progress_list import FileProgressList from jackify.shared.progress_models import InstallationPhase, InstallationProgress # Modlist gallery (imported at module level to avoid import delay when opening dialog) from jackify.frontends.gui.screens.modlist_gallery import ModlistGalleryDialog def debug_print(message): """Print debug message only if debug mode is enabled""" from jackify.backend.handlers.config_handler import ConfigHandler config_handler = ConfigHandler() if config_handler.get('debug_mode', False): print(message) class ModlistFetchThread(QThread): result = Signal(list, str) def __init__(self, game_type, log_path, mode='list-modlists'): super().__init__() self.game_type = game_type self.log_path = log_path self.mode = mode def run(self): try: # Use proper backend service - NOT the misnamed CLI class from jackify.backend.services.modlist_service import ModlistService from jackify.backend.models.configuration import SystemInfo # Initialize backend service # Detect if we're on Steam Deck is_steamdeck = False try: if os.path.exists('/etc/os-release'): with open('/etc/os-release') as f: if 'steamdeck' in f.read().lower(): is_steamdeck = True except Exception: pass system_info = SystemInfo(is_steamdeck=is_steamdeck) modlist_service = ModlistService(system_info) # Get modlists using proper backend service modlist_infos = modlist_service.list_modlists(game_type=self.game_type) # Return full modlist objects instead of just IDs to preserve enhanced metadata self.result.emit(modlist_infos, '') except Exception as e: error_msg = f"Backend service error: {str(e)}" # Don't write to log file before workflow starts - just return error self.result.emit([], error_msg) class SelectionDialog(QDialog): def __init__(self, title, items, parent=None, show_search=True, placeholder_text="Search modlists...", show_legend=False): super().__init__(parent) self.setWindowTitle(title) self.setModal(True) self.setMinimumWidth(600) self.setMinimumHeight(300) layout = QVBoxLayout(self) self.show_search = show_search if self.show_search: # Search box with clear button search_layout = QHBoxLayout() self.search_box = QLineEdit() self.search_box.setPlaceholderText(placeholder_text) # Make placeholder text lighter self.search_box.setStyleSheet("QLineEdit { color: #ccc; } QLineEdit:placeholder { color: #aaa; }") self.clear_btn = QPushButton("Clear") self.clear_btn.setFixedWidth(50) search_layout.addWidget(self.search_box) search_layout.addWidget(self.clear_btn) layout.addLayout(search_layout) if show_legend: # Use table for modlist selection with proper columns self.table_widget = QTableWidget() self.table_widget.setColumnCount(4) self.table_widget.setHorizontalHeaderLabels(["Modlist Name", "Download", "Install", "Total"]) # Configure table appearance self.table_widget.setSelectionBehavior(QTableWidget.SelectRows) self.table_widget.setSelectionMode(QTableWidget.SingleSelection) self.table_widget.verticalHeader().setVisible(False) self.table_widget.setAlternatingRowColors(True) # Set column widths header = self.table_widget.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.Stretch) # Modlist name takes remaining space header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Download size header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Install size header.setSectionResizeMode(3, QHeaderView.ResizeToContents) # Total size self._all_items = list(items) self._populate_table(self._all_items) layout.addWidget(self.table_widget) # Apply initial NSFW filter since checkbox starts unchecked self._filter_nsfw(False) else: # Use list for non-modlist dialogs (backward compatibility) self.list_widget = QListWidget() self.list_widget.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self._all_items = list(items) self._populate_list(self._all_items) layout.addWidget(self.list_widget) # Add interactive legend bar only for modlist selection dialogs if show_legend: legend_layout = QHBoxLayout() legend_layout.setContentsMargins(10, 5, 10, 5) # Status indicator explanation (far left) status_label = QLabel('[DOWN] Unavailable') status_label.setStyleSheet("color: #bbb;") legend_layout.addWidget(status_label) # Spacer after DOWN legend legend_layout.addSpacing(15) # No need for size format explanation since we have table headers now # Just add some spacing # Main spacer to push NSFW checkbox to far right legend_layout.addStretch() # NSFW filter checkbox (far right) self.nsfw_checkbox = QCheckBox("Show NSFW") self.nsfw_checkbox.setStyleSheet("color: #bbb; font-size: 11px;") self.nsfw_checkbox.setChecked(False) # Default to hiding NSFW content self.nsfw_checkbox.toggled.connect(self._filter_nsfw) legend_layout.addWidget(self.nsfw_checkbox) # Legend container legend_widget = QWidget() legend_widget.setLayout(legend_layout) legend_widget.setStyleSheet("background-color: #333; border-radius: 3px; margin: 2px;") layout.addWidget(legend_widget) self.selected_item = None # Connect appropriate signals based on widget type if show_legend: self.table_widget.itemClicked.connect(self.on_table_item_clicked) if self.show_search: self.search_box.textChanged.connect(self._filter_table) self.clear_btn.clicked.connect(self._clear_search) self.search_box.returnPressed.connect(self._focus_table) self.search_box.installEventFilter(self) else: self.list_widget.itemClicked.connect(self.on_item_clicked) if self.show_search: self.search_box.textChanged.connect(self._filter_list) self.clear_btn.clicked.connect(self._clear_search) self.search_box.returnPressed.connect(self._focus_list) self.search_box.installEventFilter(self) def _populate_list(self, items): self.list_widget.clear() for item in items: # Create list item - custom delegate handles all styling QListWidgetItem(item, self.list_widget) def _populate_table(self, items): self.table_widget.setRowCount(len(items)) for row, item in enumerate(items): # Parse the item string to extract components # Format: "[STATUS] Modlist Name Download|Install|Total" # Extract status indicators status_down = '[DOWN]' in item status_nsfw = '[NSFW]' in item # Clean the item string clean_item = item.replace('[DOWN]', '').replace('[NSFW]', '').strip() # Split into name and sizes # The format should be "Name Download|Install|Total" parts = clean_item.rsplit(' ', 1) # Split from right to separate name from sizes if len(parts) == 2: name = parts[0].strip() sizes = parts[1].strip() size_parts = sizes.split('|') if len(size_parts) == 3: download_size, install_size, total_size = [s.strip() for s in size_parts] else: # Fallback if format is unexpected download_size = install_size = total_size = sizes else: # Fallback if format is unexpected name = clean_item download_size = install_size = total_size = "" # Create table items name_item = QTableWidgetItem(name) download_item = QTableWidgetItem(download_size) install_item = QTableWidgetItem(install_size) total_item = QTableWidgetItem(total_size) # Apply styling if status_down: # Gray out and strikethrough for DOWN items for item_widget in [name_item, download_item, install_item, total_item]: item_widget.setForeground(QColor('#999999')) font = item_widget.font() font.setStrikeOut(True) item_widget.setFont(font) elif status_nsfw: # Red text for NSFW items - but only the name, sizes stay white name_item.setForeground(QColor('#ff4444')) for item_widget in [download_item, install_item, total_item]: item_widget.setForeground(QColor('#ffffff')) else: # White text for normal items for item_widget in [name_item, download_item, install_item, total_item]: item_widget.setForeground(QColor('#ffffff')) # Add status indicators to name if present if status_nsfw: name_item.setText(f"[NSFW] {name}") if status_down: # For DOWN items, we want [DOWN] normal and the name strikethrough # Since we can't easily mix fonts in a single QTableWidgetItem, # we'll style the whole item but the visual effect will be clear name_item.setText(f"[DOWN] {name_item.text()}") # Right-align size columns download_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) install_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) total_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) # Add items to table self.table_widget.setItem(row, 0, name_item) self.table_widget.setItem(row, 1, download_item) self.table_widget.setItem(row, 2, install_item) self.table_widget.setItem(row, 3, total_item) # Store original item text as data for filtering name_item.setData(Qt.UserRole, item) def _filter_list(self, text): text = text.strip().lower() if not text: filtered = self._all_items else: filtered = [item for item in self._all_items if text in item.lower()] self._populate_list(filtered) if filtered: self.list_widget.setCurrentRow(0) def _clear_search(self): self.search_box.clear() self.search_box.setFocus() def _focus_list(self): self.list_widget.setFocus() self.list_widget.setCurrentRow(0) def _focus_table(self): self.table_widget.setFocus() self.table_widget.setCurrentCell(0, 0) def _filter_table(self, text): text = text.strip().lower() if not text: # Show all rows for row in range(self.table_widget.rowCount()): self.table_widget.setRowHidden(row, False) else: # Filter rows based on modlist name for row in range(self.table_widget.rowCount()): name_item = self.table_widget.item(row, 0) if name_item: # Search in the modlist name match = text in name_item.text().lower() self.table_widget.setRowHidden(row, not match) def on_table_item_clicked(self, item): # Get the original item text from the name column row = item.row() name_item = self.table_widget.item(row, 0) if name_item: original_item = name_item.data(Qt.UserRole) self.selected_item = original_item self.accept() def _filter_nsfw(self, show_nsfw): """Filter NSFW modlists based on checkbox state""" if show_nsfw: # Show all items filtered_items = self._all_items else: # Hide NSFW items filtered_items = [item for item in self._all_items if '[NSFW]' not in item] # Use appropriate populate method based on widget type if hasattr(self, 'table_widget'): self._populate_table(filtered_items) # Apply search filter if there's search text if hasattr(self, 'search_box') and self.search_box.text().strip(): self._filter_table(self.search_box.text()) else: self._populate_list(filtered_items) # Apply search filter if there's search text if hasattr(self, 'search_box') and self.search_box.text().strip(): self._filter_list(self.search_box.text()) def eventFilter(self, obj, event): if self.show_search and obj == self.search_box and event.type() == event.Type.KeyPress: if event.key() in (Qt.Key.Key_Down, Qt.Key.Key_Tab): # Focus appropriate widget if hasattr(self, 'table_widget'): self._focus_table() else: self._focus_list() return True return super().eventFilter(obj, event) def on_item_clicked(self, item): self.selected_item = item.text() self.accept() class InstallModlistScreen(QWidget): steam_restart_finished = Signal(bool, str) resize_request = Signal(str) # Signal for expand/collapse like TTW screen def __init__(self, stacked_widget=None, main_menu_index=0): super().__init__() # Set size policy to prevent unnecessary expansion - let content determine size self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) self.stacked_widget = stacked_widget self.main_menu_index = main_menu_index self.debug = DEBUG_BORDERS # Remember original main window geometry/min-size to restore on expand (like TTW screen) self._saved_geometry = None self._saved_min_size = None self.online_modlists = {} # {game_type: [modlist_dict, ...]} self.modlist_details = {} # {modlist_name: modlist_dict} # Initialize log path (can be refreshed via refresh_paths method) self.refresh_paths() # Initialize services early from jackify.backend.services.api_key_service import APIKeyService from jackify.backend.services.nexus_auth_service import NexusAuthService from jackify.backend.services.resolution_service import ResolutionService from jackify.backend.services.protontricks_detection_service import ProtontricksDetectionService from jackify.backend.handlers.config_handler import ConfigHandler self.api_key_service = APIKeyService() self.auth_service = NexusAuthService() self.resolution_service = ResolutionService() self.config_handler = ConfigHandler() self.protontricks_service = ProtontricksDetectionService() # Somnium guidance tracking self._show_somnium_guidance = False self._somnium_install_dir = None # Console deduplication tracking self._last_console_line = None # Gallery cache preloading tracking self._gallery_cache_preload_started = False self._gallery_cache_preload_thread = None # Scroll tracking for professional auto-scroll behavior self._user_manually_scrolled = False self._was_at_bottom = True # Initialize Wabbajack parser for game detection self.wabbajack_parser = WabbajackParser() # R&D: Initialize progress reporting components self.progress_state_manager = ProgressStateManager() self.progress_indicator = OverallProgressIndicator(show_progress_bar=True) self.file_progress_list = FileProgressList() # Shows all active files (scrolls if needed) self._premium_notice_shown = False self._premium_failure_active = False self._post_install_sequence = self._build_post_install_sequence() self._post_install_total_steps = len(self._post_install_sequence) self._post_install_current_step = 0 self._post_install_active = False self._post_install_last_label = "" self._bsa_hold_deadline = 0.0 # No throttling needed - render loop handles smooth updates at 60fps # R&D: Create "Show Details" checkbox (reuse TTW pattern) self.show_details_checkbox = QCheckBox("Show details") self.show_details_checkbox.setChecked(False) # Start collapsed self.show_details_checkbox.setToolTip("Toggle between activity summary and detailed console output") self.show_details_checkbox.toggled.connect(self._on_show_details_toggled) main_overall_vbox = QVBoxLayout(self) self.main_overall_vbox = main_overall_vbox # Store reference for expand/collapse main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter) main_overall_vbox.setContentsMargins(50, 50, 50, 0) # No bottom margin main_overall_vbox.setSpacing(0) # No spacing between widgets to eliminate gaps if self.debug: self.setStyleSheet("border: 2px solid magenta;") # --- Header (title, description) --- header_layout = QVBoxLayout() header_layout.setSpacing(1) # Reduce spacing between title and description # Title (no logo) title = QLabel("Install a Modlist (Automated)") title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;") title.setAlignment(Qt.AlignHCenter) title.setMaximumHeight(30) # Force compact height header_layout.addWidget(title) # Description desc = QLabel( "This screen allows you to install a Wabbajack modlist using Jackify. " "Configure your options and start the installation." ) desc.setWordWrap(True) desc.setStyleSheet("color: #ccc; margin: 0px; padding: 0px; line-height: 1.2;") desc.setAlignment(Qt.AlignHCenter) desc.setMaximumHeight(40) # Force compact height for description header_layout.addWidget(desc) header_widget = QWidget() header_widget.setLayout(header_layout) header_widget.setMaximumHeight(75) # Increase header height by 25% (60 + 15) if self.debug: header_widget.setStyleSheet("border: 2px solid pink;") header_widget.setToolTip("HEADER_SECTION") main_overall_vbox.addWidget(header_widget) # --- Upper section: user-configurables (left) + process monitor (right) --- upper_hbox = QHBoxLayout() upper_hbox.setContentsMargins(0, 0, 0, 0) upper_hbox.setSpacing(16) upper_hbox.setAlignment(Qt.AlignTop) # Align both sides at the top # Left: user-configurables (form and controls) user_config_vbox = QVBoxLayout() user_config_vbox.setAlignment(Qt.AlignTop) user_config_vbox.setSpacing(4) # Reduce spacing between major form sections user_config_vbox.setContentsMargins(0, 0, 0, 0) # No margins to ensure tab alignment # --- Tabs for source selection --- self.source_tabs = QTabWidget() self.source_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }") self.source_tabs.setContentsMargins(0, 0, 0, 0) # Ensure no margins for alignment self.source_tabs.setDocumentMode(False) # Keep frame for consistency self.source_tabs.setTabPosition(QTabWidget.North) # Ensure tabs are at top if self.debug: self.source_tabs.setStyleSheet("border: 2px solid cyan;") self.source_tabs.setToolTip("SOURCE_TABS") # --- Online List Tab --- online_tab = QWidget() online_tab_vbox = QVBoxLayout() online_tab_vbox.setAlignment(Qt.AlignTop) # Online List Controls self.online_group = QWidget() online_layout = QHBoxLayout() online_layout.setContentsMargins(0, 0, 0, 0) # --- Game Type Selection --- self.game_types = ["Skyrim", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal", "Other"] self.game_type_btn = QPushButton("Please Select...") self.game_type_btn.setMinimumWidth(200) self.game_type_btn.clicked.connect(self.open_game_type_dialog) # --- Modlist Selection --- self.modlist_btn = QPushButton("Select Modlist") self.modlist_btn.setMinimumWidth(300) self.modlist_btn.clicked.connect(self.open_modlist_dialog) self.modlist_btn.setEnabled(False) online_layout.addWidget(QLabel("Game Type:")) online_layout.addWidget(self.game_type_btn) online_layout.addSpacing(4) # Reduced from 16 to 4 online_layout.addWidget(QLabel("Modlist:")) online_layout.addWidget(self.modlist_btn) self.online_group.setLayout(online_layout) online_tab_vbox.addWidget(self.online_group) online_tab.setLayout(online_tab_vbox) self.source_tabs.addTab(online_tab, "Select Modlist") # --- File Picker Tab --- file_tab = QWidget() file_tab_vbox = QVBoxLayout() file_tab_vbox.setAlignment(Qt.AlignTop) self.file_group = QWidget() file_layout = QHBoxLayout() file_layout.setContentsMargins(0, 0, 0, 0) self.file_edit = QLineEdit() self.file_edit.setMinimumWidth(400) self.file_btn = QPushButton("Browse") self.file_btn.clicked.connect(self.browse_wabbajack_file) file_layout.addWidget(QLabel(".wabbajack File:")) file_layout.addWidget(self.file_edit) file_layout.addWidget(self.file_btn) self.file_group.setLayout(file_layout) file_tab_vbox.addWidget(self.file_group) file_tab.setLayout(file_tab_vbox) self.source_tabs.addTab(file_tab, "Use .wabbajack File") user_config_vbox.addWidget(self.source_tabs) # --- Install/Downloads Dir/API Key (reuse Tuxborn style) --- form_grid = QGridLayout() form_grid.setHorizontalSpacing(12) form_grid.setVerticalSpacing(6) # Increased from 1 to 6 for better readability form_grid.setContentsMargins(0, 0, 0, 0) # Modlist Name (NEW FIELD) modlist_name_label = QLabel("Modlist Name:") self.modlist_name_edit = QLineEdit() self.modlist_name_edit.setMaximumHeight(25) # Force compact height form_grid.addWidget(modlist_name_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) form_grid.addWidget(self.modlist_name_edit, 0, 1) # Install Dir install_dir_label = QLabel("Install Directory:") self.install_dir_edit = QLineEdit(self.config_handler.get_modlist_install_base_dir()) self.install_dir_edit.setMaximumHeight(25) # Force compact height self.browse_install_btn = QPushButton("Browse") self.browse_install_btn.clicked.connect(self.browse_install_dir) install_dir_hbox = QHBoxLayout() install_dir_hbox.addWidget(self.install_dir_edit) install_dir_hbox.addWidget(self.browse_install_btn) form_grid.addWidget(install_dir_label, 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) form_grid.addLayout(install_dir_hbox, 1, 1) # Downloads Dir downloads_dir_label = QLabel("Downloads Directory:") self.downloads_dir_edit = QLineEdit(self.config_handler.get_modlist_downloads_base_dir()) self.downloads_dir_edit.setMaximumHeight(25) # Force compact height self.browse_downloads_btn = QPushButton("Browse") self.browse_downloads_btn.clicked.connect(self.browse_downloads_dir) downloads_dir_hbox = QHBoxLayout() downloads_dir_hbox.addWidget(self.downloads_dir_edit) downloads_dir_hbox.addWidget(self.browse_downloads_btn) form_grid.addWidget(downloads_dir_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) form_grid.addLayout(downloads_dir_hbox, 2, 1) # Nexus Login (OAuth) nexus_login_label = QLabel("Nexus Login:") self.nexus_status = QLabel("Checking...") self.nexus_status.setStyleSheet("color: #ccc;") self.nexus_login_btn = QPushButton("Authorise") self.nexus_login_btn.setStyleSheet(""" QPushButton:hover { opacity: 0.95; } QPushButton:disabled { opacity: 0.6; } """) self.nexus_login_btn.setMaximumWidth(90) self.nexus_login_btn.setVisible(False) self.nexus_login_btn.clicked.connect(self._handle_nexus_login_click) nexus_hbox = QHBoxLayout() nexus_hbox.setContentsMargins(0, 0, 0, 0) nexus_hbox.setSpacing(8) nexus_hbox.addWidget(self.nexus_login_btn) nexus_hbox.addWidget(self.nexus_status) nexus_hbox.addStretch() form_grid.addWidget(nexus_login_label, 3, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) form_grid.addLayout(nexus_hbox, 3, 1) # Update nexus status on init self._update_nexus_status() # --- Resolution Dropdown --- resolution_label = QLabel("Resolution:") self.resolution_combo = QComboBox() self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.resolution_combo.addItem("Leave unchanged") self.resolution_combo.addItems([ "1280x720", "1280x800 (Steam Deck)", "1366x768", "1440x900", "1600x900", "1600x1200", "1680x1050", "1920x1080", "1920x1200", "2048x1152", "2560x1080", "2560x1440", "2560x1600", "3440x1440", "3840x1600", "3840x2160", "3840x2400", "5120x1440", "5120x2160", "7680x4320" ]) # Load saved resolution if available saved_resolution = self.resolution_service.get_saved_resolution() is_steam_deck = False try: if os.path.exists('/etc/os-release'): with open('/etc/os-release') as f: if 'steamdeck' in f.read().lower(): is_steam_deck = True except Exception: pass if saved_resolution: combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items) self.resolution_combo.setCurrentIndex(resolution_index) debug_print(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})") elif is_steam_deck: # Set default to 1280x800 (Steam Deck) combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] if "1280x800 (Steam Deck)" in combo_items: self.resolution_combo.setCurrentIndex(combo_items.index("1280x800 (Steam Deck)")) else: self.resolution_combo.setCurrentIndex(0) # Otherwise, default is 'Leave unchanged' (index 0) form_grid.addWidget(resolution_label, 5, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) # Horizontal layout for resolution dropdown and auto-restart checkbox resolution_and_restart_layout = QHBoxLayout() resolution_and_restart_layout.setSpacing(12) # Resolution dropdown (made smaller) self.resolution_combo.setMaximumWidth(280) # Constrain width but keep aesthetically pleasing resolution_and_restart_layout.addWidget(self.resolution_combo) # Add stretch to push checkbox to the right resolution_and_restart_layout.addStretch() # Auto-accept Steam restart checkbox (right-aligned) self.auto_restart_checkbox = QCheckBox("Auto-accept Steam restart") self.auto_restart_checkbox.setChecked(False) # Always default to unchecked per session self.auto_restart_checkbox.setToolTip("When checked, Steam restart dialog will be automatically accepted, allowing unattended installation") resolution_and_restart_layout.addWidget(self.auto_restart_checkbox) form_grid.addLayout(resolution_and_restart_layout, 5, 1) form_section_widget = QWidget() form_section_widget.setLayout(form_grid) # Let form section size naturally to its content # Don't force a fixed height - let it calculate based on grid content form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) if self.debug: form_section_widget.setStyleSheet("border: 2px solid blue;") form_section_widget.setToolTip("FORM_SECTION") user_config_vbox.addWidget(form_section_widget) # --- Buttons --- btn_row = QHBoxLayout() btn_row.setAlignment(Qt.AlignHCenter) self.start_btn = QPushButton("Start Installation") btn_row.addWidget(self.start_btn) # Cancel button (goes back to menu) self.cancel_btn = QPushButton("Cancel") self.cancel_btn.clicked.connect(self.cancel_and_cleanup) btn_row.addWidget(self.cancel_btn) # Cancel Installation button (appears during installation) self.cancel_install_btn = QPushButton("Cancel Installation") self.cancel_install_btn.clicked.connect(self.cancel_installation) self.cancel_install_btn.setVisible(False) # Hidden by default btn_row.addWidget(self.cancel_install_btn) # Wrap button row in widget for debug borders btn_row_widget = QWidget() btn_row_widget.setLayout(btn_row) btn_row_widget.setMaximumHeight(50) # Limit height to make it more compact if self.debug: btn_row_widget.setStyleSheet("border: 2px solid red;") btn_row_widget.setToolTip("BUTTON_ROW") user_config_widget = QWidget() self.user_config_widget = user_config_widget # Store reference for height calculation user_config_widget.setLayout(user_config_vbox) user_config_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) # Fixed height - don't expand unnecessarily if self.debug: user_config_widget.setStyleSheet("border: 2px solid orange;") user_config_widget.setToolTip("USER_CONFIG_WIDGET") # Right: Tabbed interface with Activity and Process Monitor # Both tabs are always available, user can switch between them self.process_monitor = QTextEdit() self.process_monitor.setReadOnly(True) self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) self.process_monitor.setMinimumSize(QSize(300, 20)) self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;") self.process_monitor_heading = QLabel("[Process Monitor]") self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;") self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) process_vbox = QVBoxLayout() process_vbox.setContentsMargins(0, 0, 0, 0) process_vbox.setSpacing(2) process_vbox.addWidget(self.process_monitor_heading) process_vbox.addWidget(self.process_monitor) process_monitor_widget = QWidget() process_monitor_widget.setLayout(process_vbox) # Match size policy - Process Monitor should expand to fill available space process_monitor_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) if self.debug: process_monitor_widget.setStyleSheet("border: 2px solid purple;") process_monitor_widget.setToolTip("PROCESS_MONITOR") # Store reference self.process_monitor_widget = process_monitor_widget # Set up File Progress List (Activity tab) # Match Process Monitor size policy exactly - expand to fill available space self.file_progress_list.setMinimumSize(QSize(300, 20)) self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # Create tab widget to hold both Activity and Process Monitor # Match styling of source_tabs on the left for consistency self.activity_tabs = QTabWidget() self.activity_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }") self.activity_tabs.setContentsMargins(0, 0, 0, 0) # Ensure no margins for alignment self.activity_tabs.setDocumentMode(False) # Match left tabs self.activity_tabs.setTabPosition(QTabWidget.North) # Ensure tabs are at top if self.debug: self.activity_tabs.setStyleSheet("border: 2px solid cyan;") self.activity_tabs.setToolTip("ACTIVITY_TABS") # Add both widgets as tabs self.activity_tabs.addTab(self.file_progress_list, "Activity") self.activity_tabs.addTab(process_monitor_widget, "Process Monitor") upper_hbox.addWidget(user_config_widget, stretch=1, alignment=Qt.AlignTop) # Add tab widget with stretch=3 to match original Process Monitor stretch upper_hbox.addWidget(self.activity_tabs, stretch=3, alignment=Qt.AlignTop) upper_section_widget = QWidget() self.upper_section_widget = upper_section_widget # Store reference for showEvent upper_section_widget.setLayout(upper_hbox) # Use Fixed size policy - the height should be based on LEFT side only # This ensures consistent height whether Active Files or Process Monitor is shown upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Calculate height based on LEFT side (user_config_widget) only # This ensures the same height regardless of which right widget is visible self._upper_section_fixed_height = None # Will be set in showEvent based on left side if self.debug: upper_section_widget.setStyleSheet("border: 2px solid green;") upper_section_widget.setToolTip("UPPER_SECTION") main_overall_vbox.addWidget(upper_section_widget) # Add spacing between upper section and progress banner main_overall_vbox.addSpacing(8) # R&D: Progress indicator banner row (similar to TTW screen) banner_row = QHBoxLayout() banner_row.setContentsMargins(0, 0, 0, 0) banner_row.setSpacing(8) banner_row.addWidget(self.progress_indicator, 1) banner_row.addStretch() banner_row.addWidget(self.show_details_checkbox) banner_row_widget = QWidget() banner_row_widget.setLayout(banner_row) # Constrain height to prevent unwanted vertical expansion banner_row_widget.setMaximumHeight(45) # Compact height: 34px label + small margin banner_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) main_overall_vbox.addWidget(banner_row_widget) # Add spacing between progress banner and console/details area main_overall_vbox.addSpacing(8) # R&D: File progress list is now in the upper section (replacing Process Monitor) # Console shows below when "Show details" is checked # NOTE: File progress list is already added to upper_hbox above # Remove spacing - console should expand to fill available space # --- Console output area (full width, placeholder for now) --- self.console = QTextEdit() self.console.setReadOnly(True) self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) # R&D: Console starts hidden (only shows when "Show details" is checked) self.console.setMinimumHeight(0) self.console.setMaximumHeight(0) self.console.setVisible(False) self.console.setFontFamily('monospace') if self.debug: self.console.setStyleSheet("border: 2px solid yellow;") self.console.setToolTip("CONSOLE") # Set up scroll tracking for professional auto-scroll behavior self._setup_scroll_tracking() # Create a container that holds console + button row with proper spacing console_and_buttons_widget = QWidget() console_and_buttons_layout = QVBoxLayout() console_and_buttons_layout.setContentsMargins(0, 0, 0, 0) console_and_buttons_layout.setSpacing(0) # No spacing - console is hidden initially # Console with stretch only when visible, buttons always at natural size console_and_buttons_layout.addWidget(self.console) # No stretch initially - will be set dynamically console_and_buttons_layout.addWidget(btn_row_widget) # Buttons at bottom of this container console_and_buttons_widget.setLayout(console_and_buttons_layout) self.console_and_buttons_widget = console_and_buttons_widget # Store reference for stretch control self.console_and_buttons_layout = console_and_buttons_layout # Store reference for spacing control # Use Minimum size policy - takes only the minimum space needed console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) # Constrain height to button row only when console is hidden - match button row height exactly # Button row is 50px max, so container should be exactly that when collapsed console_and_buttons_widget.setFixedHeight(50) # Lock to exact button row height when console is hidden if self.debug: console_and_buttons_widget.setStyleSheet("border: 2px solid lightblue;") console_and_buttons_widget.setToolTip("CONSOLE_AND_BUTTONS_CONTAINER") # Add without stretch - let it size naturally to content main_overall_vbox.addWidget(console_and_buttons_widget) self.setLayout(main_overall_vbox) self.current_modlists = [] # --- Process Monitor (right) --- self.process = None self.log_timer = None self.last_log_pos = 0 # --- Process Monitor Timer --- self.top_timer = QTimer(self) self.top_timer.timeout.connect(self.update_top_panel) self.top_timer.start(2000) # --- Start Installation button --- self.start_btn.clicked.connect(self.validate_and_start_install) self.steam_restart_finished.connect(self._on_steam_restart_finished) # Initialize process tracking self.process = None # Initialize empty controls list - will be populated after UI is built self._actionable_controls = [] # Now collect all actionable controls after UI is fully built self._collect_actionable_controls() def _collect_actionable_controls(self): """Collect all actionable controls that should be disabled during operations (except Cancel)""" self._actionable_controls = [ # Main action button self.start_btn, # Game/modlist selection self.game_type_btn, self.modlist_btn, # Source tabs (entire tab widget) self.source_tabs, # Form fields self.modlist_name_edit, self.install_dir_edit, self.downloads_dir_edit, self.file_edit, # Browse buttons self.browse_install_btn, self.browse_downloads_btn, self.file_btn, # Resolution controls self.resolution_combo, # Nexus login button self.nexus_login_btn, # Checkboxes self.auto_restart_checkbox, ] def _disable_controls_during_operation(self): """Disable all actionable controls during install/configure operations (except Cancel)""" for control in self._actionable_controls: if control: control.setEnabled(False) def _enable_controls_after_operation(self): """Re-enable all actionable controls after install/configure operations complete""" for control in self._actionable_controls: if control: control.setEnabled(True) def _abort_install_validation(self): """Reset UI state when validation is aborted early.""" self._enable_controls_after_operation() self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) self.progress_indicator.reset() self.process_monitor.clear() def _abort_with_message(self, level: str, title: str, message: str, **kwargs): """Show a message and abort the validation workflow.""" messenger = getattr(MessageService, level, MessageService.warning) messenger(self, title, message, **kwargs) self._abort_install_validation() def refresh_paths(self): """Refresh cached paths when config changes.""" from jackify.shared.paths import get_jackify_logs_dir self.modlist_log_path = get_jackify_logs_dir() / 'Modlist_Install_workflow.log' os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True) def _open_url_safe(self, url): """Safely open URL via subprocess to avoid Qt library clashes inside the AppImage runtime""" import subprocess try: subprocess.Popen(['xdg-open', url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except Exception as e: print(f"Warning: Could not open URL {url}: {e}") def resizeEvent(self, event): """Handle window resize to prioritize form over console""" super().resizeEvent(event) self._adjust_console_for_form_priority() def _adjust_console_for_form_priority(self): """Console now dynamically fills available space with stretch=1, no manual calculation needed""" # The console automatically fills remaining space due to stretch=1 in the layout # Remove any fixed height constraints to allow natural stretching self.console.setMaximumHeight(16777215) # Reset to default maximum self.console.setMinimumHeight(50) # Keep minimum height for usability def showEvent(self, event): """Called when the widget becomes visible - ensure collapsed state""" super().showEvent(event) # Refresh Nexus auth status when screen becomes visible # This ensures auth status is updated after user completes OAuth from Settings menu self._update_nexus_status() # Do NOT load saved parent directories # Note: Gallery cache preload now happens at app startup (see JackifyMainWindow.__init__) # This ensures cache is ready before user even navigates to this screen # Ensure initial collapsed layout each time this screen is opened (like TTW screen) try: from PySide6.QtCore import Qt as _Qt # Ensure checkbox is unchecked without emitting signals if self.show_details_checkbox.isChecked(): self.show_details_checkbox.blockSignals(True) self.show_details_checkbox.setChecked(False) self.show_details_checkbox.blockSignals(False) # Force collapsed state self._toggle_console_visibility(_Qt.Unchecked) # Force the window to compact height main_window = self.window() if main_window: # Save original geometry once if self._saved_geometry is None: self._saved_geometry = main_window.geometry() if self._saved_min_size is None: self._saved_min_size = main_window.minimumSize() # Use Qt's standard approach: let layout size naturally, only set minimum # This allows manual resizing and prevents content cut-off from PySide6.QtCore import QTimer, QSize from PySide6.QtWidgets import QApplication def calculate_and_set_upper_section_height(): """Calculate and lock the upper section height based on left side only""" try: if hasattr(self, 'upper_section_widget') and self.upper_section_widget is not None: # Only calculate if we haven't stored it yet if not hasattr(self, '_upper_section_fixed_height') or self._upper_section_fixed_height is None: # Calculate height based on LEFT side (user_config_widget) only if hasattr(self, 'user_config_widget') and self.user_config_widget is not None: # Force layout updates to ensure everything is calculated self.user_config_widget.updateGeometry() self.user_config_widget.layout().update() self.updateGeometry() self.layout().update() QApplication.processEvents() # Get the natural height of the left side left_height = self.user_config_widget.sizeHint().height() # Add a small margin for spacing self._upper_section_fixed_height = left_height + 20 else: # Fallback: use sizeHint of upper section self.upper_section_widget.updateGeometry() self._upper_section_fixed_height = self.upper_section_widget.sizeHint().height() # Lock the height - same in both modes self.upper_section_widget.setMaximumHeight(self._upper_section_fixed_height) self.upper_section_widget.setMinimumHeight(self._upper_section_fixed_height) except Exception as e: if self.debug: print(f"DEBUG: Error calculating upper section height: {e}") pass # Calculate heights immediately after forcing layout update # This prevents visible layout shift self.updateGeometry() self.layout().update() QApplication.processEvents() # Calculate upper section height immediately calculate_and_set_upper_section_height() # Only set minimum size - DO NOT RESIZE from PySide6.QtCore import QSize # On Steam Deck, keep fullscreen; on other systems, set normal window state if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck): main_window.showNormal() main_window.setMaximumSize(QSize(16777215, 16777215)) set_responsive_minimum(main_window, min_width=960, min_height=420) # DO NOT resize - let window stay at current size except Exception as e: debug_print(f"DEBUG: showEvent exception: {e}") def _start_gallery_cache_preload(self): """DEPRECATED: Gallery cache preload now happens at app startup in JackifyMainWindow""" # Only start once per session if self._gallery_cache_preload_started: return self._gallery_cache_preload_started = True # Create background thread to preload gallery cache class GalleryCachePreloadThread(QThread): finished_signal = Signal(bool, str) # success, message def run(self): try: from jackify.backend.services.modlist_gallery_service import ModlistGalleryService service = ModlistGalleryService() # Fetch with search index to build cache (this will take time but is invisible) # Use force_refresh=False to allow using existing cache if it has mods metadata = service.fetch_modlist_metadata( include_validation=False, # Skip validation for speed include_search_index=True, # Include mods for search sort_by="title", force_refresh=False # Use cache if it has mods, otherwise fetch fresh ) if metadata: # Check if we got mods modlists_with_mods = sum(1 for m in metadata.modlists if hasattr(m, 'mods') and m.mods) if modlists_with_mods > 0: debug_print(f"DEBUG: Gallery cache ready ({modlists_with_mods} modlists with mods)") else: # Cache didn't have mods, but we fetched fresh - should have mods now debug_print("DEBUG: Gallery cache updated") else: debug_print("DEBUG: Failed to load gallery cache") except Exception as e: debug_print(f"DEBUG: Gallery cache preload error: {str(e)}") # Start thread (non-blocking, invisible to user) self._gallery_cache_preload_thread = GalleryCachePreloadThread() # Don't connect finished signal - we don't need to do anything, just let it run self._gallery_cache_preload_thread.start() debug_print("DEBUG: Started background gallery cache preload") def hideEvent(self, event): """Called when the widget is hidden - restore window size constraints""" super().hideEvent(event) try: # Check if we're on Steam Deck - if so, clear constraints to prevent affecting other screens main_window = self.window() is_steamdeck = False if hasattr(main_window, 'system_info') and main_window.system_info: is_steamdeck = getattr(main_window.system_info, 'is_steamdeck', False) if main_window: from PySide6.QtCore import QSize # Clear any size constraints that might have been set to prevent affecting other screens # This is especially important for Steam Deck but also helps on desktop main_window.setMaximumSize(QSize(16777215, 16777215)) main_window.setMinimumSize(QSize(0, 0)) debug_print("DEBUG: Install Modlist hideEvent - cleared window size constraints") except Exception as e: debug_print(f"DEBUG: hideEvent exception: {e}") pass def _load_saved_parent_directories(self): """No-op: do not pre-populate install/download directories from saved values.""" pass def _update_directory_suggestions(self, modlist_name): """Update directory suggestions based on modlist name""" try: if not modlist_name: return # Update install directory suggestion with modlist name saved_install_parent = self.config_handler.get_default_install_parent_dir() if saved_install_parent: suggested_install_dir = os.path.join(saved_install_parent, modlist_name) self.install_dir_edit.setText(suggested_install_dir) debug_print(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}") # Update download directory suggestion saved_download_parent = self.config_handler.get_default_download_parent_dir() if saved_download_parent: suggested_download_dir = os.path.join(saved_download_parent, "Downloads") self.downloads_dir_edit.setText(suggested_download_dir) debug_print(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}") except Exception as e: debug_print(f"DEBUG: Error updating directory suggestions: {e}") def _save_parent_directories(self, install_dir, downloads_dir): """Removed automatic saving - user should set defaults in settings""" pass def _update_nexus_status(self): """Update the Nexus login status display""" authenticated, method, username = self.auth_service.get_auth_status() if authenticated and method == 'oauth': # OAuth authorised status_text = "Authorised" if username: status_text += f" ({username})" self.nexus_status.setText(status_text) self.nexus_status.setStyleSheet("color: #3fd0ea;") self.nexus_login_btn.setText("Revoke") self.nexus_login_btn.setVisible(True) elif authenticated and method == 'api_key': # API Key in use (fallback - configured in Settings) self.nexus_status.setText("API Key") self.nexus_status.setStyleSheet("color: #FFA726;") self.nexus_login_btn.setText("Authorise") self.nexus_login_btn.setVisible(True) else: # Not authorised self.nexus_status.setText("Not Authorised") self.nexus_status.setStyleSheet("color: #f44336;") self.nexus_login_btn.setText("Authorise") self.nexus_login_btn.setVisible(True) def _show_copyable_url_dialog(self, url: str): """Show a dialog with a copyable URL""" from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout, QApplication from PySide6.QtCore import Qt dialog = QDialog(self) dialog.setWindowTitle("Manual Browser Open Required") dialog.setModal(True) dialog.setMinimumWidth(600) layout = QVBoxLayout() layout.setSpacing(15) # Explanation label info_label = QLabel( "Could not open browser automatically.\n\n" "Please copy the URL below and paste it into your browser:" ) info_label.setWordWrap(True) info_label.setStyleSheet("color: #ccc; font-size: 12px;") layout.addWidget(info_label) # URL input (read-only but selectable) url_input = QLineEdit() url_input.setText(url) url_input.setReadOnly(True) url_input.selectAll() # Pre-select text for easy copying url_input.setStyleSheet(""" QLineEdit { background-color: #1a1a1a; color: #3fd0ea; border: 1px solid #444; border-radius: 4px; padding: 8px; font-family: monospace; font-size: 11px; } """) layout.addWidget(url_input) # Button row button_layout = QHBoxLayout() button_layout.addStretch() # Copy button copy_btn = QPushButton("Copy URL") copy_btn.setStyleSheet(""" QPushButton { background-color: #3fd0ea; color: #000; border: none; border-radius: 4px; padding: 8px 20px; font-weight: bold; } QPushButton:hover { background-color: #5fdfff; } """) def copy_to_clipboard(): clipboard = QApplication.clipboard() clipboard.setText(url) copy_btn.setText("Copied!") copy_btn.setEnabled(False) copy_btn.clicked.connect(copy_to_clipboard) button_layout.addWidget(copy_btn) # Close button close_btn = QPushButton("Close") close_btn.setStyleSheet(""" QPushButton { background-color: #444; color: #ccc; border: none; border-radius: 4px; padding: 8px 20px; } QPushButton:hover { background-color: #555; } """) close_btn.clicked.connect(dialog.accept) button_layout.addWidget(close_btn) layout.addLayout(button_layout) dialog.setLayout(layout) dialog.exec() def _handle_nexus_login_click(self): """Handle Nexus login button click""" from jackify.frontends.gui.services.message_service import MessageService from PySide6.QtWidgets import QMessageBox, QProgressDialog, QApplication from PySide6.QtCore import Qt, QThread, Signal authenticated, method, _ = self.auth_service.get_auth_status() if authenticated and method == 'oauth': # OAuth is active - offer to revoke reply = MessageService.question(self, "Revoke", "Revoke OAuth authorisation?", safety_level="low") if reply == QMessageBox.Yes: self.auth_service.revoke_oauth() self._update_nexus_status() else: # Not authorised or using API key - offer to authorise with OAuth reply = MessageService.question(self, "Authorise with Nexus", "Your browser will open for Nexus authorisation.\n\n" "Note: Your browser may ask permission to open 'xdg-open'\n" "or Jackify's protocol handler - please click 'Open' or 'Allow'.\n\n" "Please log in and authorise Jackify when prompted.\n\n" "Continue?", safety_level="low") if reply != QMessageBox.Yes: return # Create progress dialog progress = QProgressDialog( "Waiting for authorisation...\n\nPlease check your browser.", "Cancel", 0, 0, self ) progress.setWindowTitle("Nexus OAuth") progress.setWindowModality(Qt.WindowModal) progress.setMinimumDuration(0) progress.setMinimumWidth(400) # Track cancellation oauth_cancelled = [False] def on_cancel(): oauth_cancelled[0] = True progress.canceled.connect(on_cancel) progress.show() QApplication.processEvents() # Create OAuth thread to prevent GUI freeze class OAuthThread(QThread): finished_signal = Signal(bool) message_signal = Signal(str) manual_url_signal = Signal(str) # Signal when browser fails to open def __init__(self, auth_service, parent=None): super().__init__(parent) self.auth_service = auth_service def run(self): def show_message(msg): # Check if this is a "browser failed" message with URL if "Could not open browser" in msg and "Please open this URL manually:" in msg: # Extract URL from message url_start = msg.find("Please open this URL manually:") + len("Please open this URL manually:") url = msg[url_start:].strip() self.manual_url_signal.emit(url) else: self.message_signal.emit(msg) success = self.auth_service.authorize_oauth(show_browser_message_callback=show_message) self.finished_signal.emit(success) oauth_thread = OAuthThread(self.auth_service, self) # Connect message signal to update progress dialog def update_progress_message(msg): if not oauth_cancelled[0]: progress.setLabelText(f"Waiting for authorisation...\n\n{msg}") QApplication.processEvents() # Connect manual URL signal to show copyable dialog def show_manual_url_dialog(url): if not oauth_cancelled[0]: progress.hide() # Hide progress dialog temporarily self._show_copyable_url_dialog(url) progress.show() oauth_thread.message_signal.connect(update_progress_message) oauth_thread.manual_url_signal.connect(show_manual_url_dialog) # Wait for thread completion oauth_success = [False] def on_oauth_finished(success): oauth_success[0] = success oauth_thread.finished_signal.connect(on_oauth_finished) oauth_thread.start() # Wait for thread to finish (non-blocking event loop) while oauth_thread.isRunning(): QApplication.processEvents() oauth_thread.wait(100) # Check every 100ms if oauth_cancelled[0]: # User cancelled - thread will still complete but we ignore result oauth_thread.wait(2000) if oauth_thread.isRunning(): oauth_thread.terminate() break progress.close() QApplication.processEvents() self._update_nexus_status() self._enable_controls_after_operation() # Check success first - if OAuth succeeded, ignore cancellation flag # (progress dialog close can trigger cancel handler even on success) if oauth_success[0]: _, _, username = self.auth_service.get_auth_status() if username: msg = f"OAuth authorisation successful!

Authorised as: {username}" else: msg = "OAuth authorisation successful!" MessageService.information(self, "Success", msg, safety_level="low") elif oauth_cancelled[0]: MessageService.information(self, "Cancelled", "OAuth authorisation cancelled.", safety_level="low") else: MessageService.warning( self, "Authorisation Failed", "OAuth authorisation failed.\n\n" "If you see 'redirect URI mismatch' in your browser,\n" "the OAuth redirect URI needs to be configured by Nexus.\n\n" "You can configure an API key in Settings as a fallback.", safety_level="medium" ) def open_game_type_dialog(self): dlg = SelectionDialog("Select Game Type", self.game_types, self, show_search=False) if dlg.exec() == QDialog.Accepted and dlg.selected_item: self.game_type_btn.setText(dlg.selected_item) # Store game type for gallery filter self.current_game_type = dlg.selected_item # Enable modlist button immediately - gallery will fetch its own data self.modlist_btn.setEnabled(True) self.modlist_btn.setText("Select Modlist") # No need to fetch modlists here - gallery does it when opened def fetch_modlists_for_game_type(self, game_type): self.current_game_type = game_type # Store for display formatting self.modlist_btn.setText("Fetching modlists...") self.modlist_btn.setEnabled(False) game_type_map = { "Skyrim": "skyrim", "Fallout 4": "fallout4", "Fallout New Vegas": "falloutnv", "Oblivion": "oblivion", "Starfield": "starfield", "Oblivion Remastered": "oblivion_remastered", "Enderal": "enderal", "Other": "other" } cli_game_type = game_type_map.get(game_type, "other") log_path = self.modlist_log_path # Use backend service directly - NO CLI CALLS self.fetch_thread = ModlistFetchThread( cli_game_type, log_path, mode='list-modlists') self.fetch_thread.result.connect(self.on_modlists_fetched) self.fetch_thread.start() def on_modlists_fetched(self, modlist_infos, error): # Handle the case where modlist_infos might be strings (backward compatibility) if modlist_infos and isinstance(modlist_infos[0], str): # Old format - just IDs as strings filtered = [m for m in modlist_infos if m and not m.startswith('DEBUG:')] self.current_modlists = filtered self.current_modlist_display = filtered else: # New format - full modlist objects with enhanced metadata filtered_modlists = [m for m in modlist_infos if m and hasattr(m, 'id')] filtered = filtered_modlists # Set filtered for the condition check below self.current_modlists = [m.id for m in filtered_modlists] # Keep IDs for selection # Create enhanced display strings with size info and status indicators display_strings = [] for modlist in filtered_modlists: # Get enhanced metadata download_size = getattr(modlist, 'download_size', '') install_size = getattr(modlist, 'install_size', '') total_size = getattr(modlist, 'total_size', '') status_down = getattr(modlist, 'status_down', False) status_nsfw = getattr(modlist, 'status_nsfw', False) # Format display string without redundant game type: "Modlist Name - Download|Install|Total" # For "Other" category, include game type in brackets for clarity # Use padding to create alignment: left-aligned name, right-aligned sizes if hasattr(self, 'current_game_type') and self.current_game_type == "Other": name_part = f"{modlist.name} [{modlist.game}]" else: name_part = modlist.name size_part = f"{download_size}|{install_size}|{total_size}" # Create aligned display using string formatting (approximate alignment) display_str = f"{name_part:<50} {size_part:>15}" # Add status indicators at the beginning if present if status_down or status_nsfw: status_parts = [] if status_down: status_parts.append("[DOWN]") if status_nsfw: status_parts.append("[NSFW]") display_str = " ".join(status_parts) + " " + display_str display_strings.append(display_str) self.current_modlist_display = display_strings # Create mapping from display string back to modlist ID for selection self._modlist_id_map = {} if len(self.current_modlist_display) == len(self.current_modlists): self._modlist_id_map = {display: modlist_id for display, modlist_id in zip(self.current_modlist_display, self.current_modlists)} else: # Fallback for backward compatibility self._modlist_id_map = {mid: mid for mid in self.current_modlists} if error: self.modlist_btn.setText("Error fetching modlists.") self.modlist_btn.setEnabled(False) # Don't write to log file before workflow starts - just show error in UI elif filtered: self.modlist_btn.setText("Select Modlist") self.modlist_btn.setEnabled(True) else: self.modlist_btn.setText("No modlists found.") self.modlist_btn.setEnabled(False) def open_modlist_dialog(self): # CRITICAL: Prevent opening gallery without game type selected # This prevents potential issues with engine path resolution and subprocess spawning if not hasattr(self, 'current_game_type') or not self.current_game_type: from PySide6.QtWidgets import QMessageBox QMessageBox.warning( self, "Game Type Required", "Please select a game type before opening the modlist gallery." ) return from PySide6.QtWidgets import QApplication self.modlist_btn.setEnabled(False) cursor_overridden = False try: QApplication.setOverrideCursor(Qt.WaitCursor) cursor_overridden = True game_type_to_human_friendly = { "Skyrim": "Skyrim Special Edition", "Fallout 4": "Fallout 4", "Fallout New Vegas": "Fallout New Vegas", "Oblivion": "Oblivion", "Starfield": "Starfield", "Oblivion Remastered": "Oblivion", "Enderal": "Enderal Special Edition", "Other": None } game_filter = None if hasattr(self, 'current_game_type'): game_filter = game_type_to_human_friendly.get(self.current_game_type) dlg = ModlistGalleryDialog(game_filter=game_filter, parent=self) if cursor_overridden: QApplication.restoreOverrideCursor() cursor_overridden = False if dlg.exec() == QDialog.Accepted and dlg.selected_metadata: metadata = dlg.selected_metadata self.modlist_btn.setText(metadata.title) self.selected_modlist_info = { 'machine_url': metadata.namespacedName, 'title': metadata.title, 'author': metadata.author, 'game': metadata.gameHumanFriendly, 'description': metadata.description, 'nsfw': metadata.nsfw, 'force_down': metadata.forceDown } self.modlist_name_edit.setText(metadata.title) # Auto-append modlist name to install directory base_install_dir = self.config_handler.get_modlist_install_base_dir() if base_install_dir: # Sanitize modlist title for filesystem use import re safe_title = re.sub(r'[<>:"/\\|?*]', '', metadata.title) safe_title = safe_title.strip() modlist_install_path = os.path.join(base_install_dir, safe_title) self.install_dir_edit.setText(modlist_install_path) finally: if cursor_overridden: QApplication.restoreOverrideCursor() self.modlist_btn.setEnabled(True) def browse_wabbajack_file(self): file, _ = QFileDialog.getOpenFileName(self, "Select .wabbajack File", os.path.expanduser("~"), "Wabbajack Files (*.wabbajack)") if file: self.file_edit.setText(file) def browse_install_dir(self): dir = QFileDialog.getExistingDirectory(self, "Select Install Directory", self.install_dir_edit.text()) if dir: self.install_dir_edit.setText(dir) def browse_downloads_dir(self): dir = QFileDialog.getExistingDirectory(self, "Select Downloads Directory", self.downloads_dir_edit.text()) if dir: self.downloads_dir_edit.setText(dir) def go_back(self): """Navigate back to main menu and restore window size""" # Emit collapse signal to restore compact mode self.resize_request.emit('collapse') # Restore window size before navigating away try: main_window = self.window() if main_window: from PySide6.QtCore import QSize from ..utils import apply_window_size_and_position # Only set minimum size - DO NOT RESIZE main_window.setMaximumSize(QSize(16777215, 16777215)) set_responsive_minimum(main_window, min_width=960, min_height=420) # DO NOT resize - let window stay at current size except Exception: pass if self.stacked_widget: self.stacked_widget.setCurrentIndex(self.main_menu_index) def update_top_panel(self): try: result = subprocess.run([ "ps", "-eo", "pcpu,pmem,comm,args" ], stdout=subprocess.PIPE, text=True, timeout=2) lines = result.stdout.splitlines() header = "CPU%\tMEM%\tCOMMAND" filtered = [header] process_rows = [] for line in lines[1:]: line_lower = line.lower() if ( ("jackify-engine" in line_lower or "7zz" in line_lower or "texconv" in line_lower or "wine" in line_lower or "wine64" in line_lower or "protontricks" in line_lower or "hoolamike" in line_lower) and "jackify-gui.py" not in line_lower ): cols = line.strip().split(None, 3) if len(cols) >= 3: process_rows.append(cols) process_rows.sort(key=lambda x: float(x[0]), reverse=True) for cols in process_rows: filtered.append('\t'.join(cols)) if len(filtered) == 1: filtered.append("[No Jackify-related processes found]") self.process_monitor.setPlainText('\n'.join(filtered)) except Exception as e: self.process_monitor.setPlainText(f"[process info unavailable: {e}]") def _check_protontricks(self): """Check if protontricks is available before critical operations""" try: if self.protontricks_service.is_bundled_mode(): return True is_installed, installation_type, details = self.protontricks_service.detect_protontricks() if not is_installed: # Show protontricks error dialog from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog dialog = ProtontricksErrorDialog(self.protontricks_service, self) result = dialog.exec() if result == QDialog.Rejected: return False # Re-check after dialog is_installed, _, _ = self.protontricks_service.detect_protontricks(use_cache=False) return is_installed return True except Exception as e: print(f"Error checking protontricks: {e}") MessageService.warning(self, "Protontricks Check Failed", f"Unable to verify protontricks installation: {e}\n\n" "Continuing anyway, but some features may not work correctly.") return True # Continue anyway def _check_ttw_eligibility(self, modlist_name: str, game_type: str, install_dir: str) -> bool: """Check if modlist is FNV, TTW-compatible, and doesn't already have TTW Args: modlist_name: Name of the installed modlist game_type: Game type (e.g., 'falloutnv') install_dir: Modlist installation directory Returns: bool: True if should offer TTW integration """ try: # Check 1: Must be Fallout New Vegas if game_type.lower() not in ['falloutnv', 'fallout new vegas', 'fallout_new_vegas']: return False # Check 2: Must be on whitelist from jackify.backend.data.ttw_compatible_modlists import is_ttw_compatible if not is_ttw_compatible(modlist_name): return False # Check 3: TTW must not already be installed if self._detect_existing_ttw(install_dir): debug_print("DEBUG: TTW already installed, skipping prompt") return False return True except Exception as e: debug_print(f"DEBUG: Error checking TTW eligibility: {e}") return False def _detect_existing_ttw(self, install_dir: str) -> bool: """Check if TTW is already installed in the modlist Args: install_dir: Modlist installation directory Returns: bool: True if TTW is already present """ try: from pathlib import Path mods_dir = Path(install_dir) / "mods" if not mods_dir.exists(): return False # Check for folders containing "Tale of Two Wastelands" that have actual TTW content # Exclude separators and placeholder folders for folder in mods_dir.iterdir(): if not folder.is_dir(): continue folder_name_lower = folder.name.lower() # Skip separator folders and placeholders if "_separator" in folder_name_lower or "put" in folder_name_lower or "here" in folder_name_lower: continue # Check if folder name contains TTW indicator if "tale of two wastelands" in folder_name_lower: # Verify it has actual TTW content by checking for the main ESM ttw_esm = folder / "TaleOfTwoWastelands.esm" if ttw_esm.exists(): debug_print(f"DEBUG: Found existing TTW installation: {folder.name}") return True else: debug_print(f"DEBUG: Found TTW folder but no ESM, skipping: {folder.name}") return False except Exception as e: debug_print(f"DEBUG: Error detecting existing TTW: {e}") return False # Assume not installed on error def _initiate_ttw_workflow(self, modlist_name: str, install_dir: str): """Navigate to TTW screen and set it up for modlist integration Args: modlist_name: Name of the modlist that needs TTW integration install_dir: Path to the modlist installation directory """ try: # Store modlist context for later use when TTW completes self._ttw_modlist_name = modlist_name self._ttw_install_dir = install_dir # Get reference to TTW screen BEFORE navigation if self.stacked_widget: ttw_screen = self.stacked_widget.widget(5) # Set integration mode BEFORE navigating to avoid showEvent race condition if hasattr(ttw_screen, 'set_modlist_integration_mode'): ttw_screen.set_modlist_integration_mode(modlist_name, install_dir) # Connect to completion signal to show success dialog after TTW if hasattr(ttw_screen, 'integration_complete'): ttw_screen.integration_complete.connect(self._on_ttw_integration_complete) else: debug_print("WARNING: TTW screen does not support modlist integration mode yet") # Navigate to TTW screen AFTER setting integration mode self.stacked_widget.setCurrentIndex(5) # Force collapsed state shortly after navigation to avoid any # showEvent/layout timing races that may leave it expanded try: from PySide6.QtCore import QTimer QTimer.singleShot(50, lambda: getattr(ttw_screen, 'force_collapsed_state', lambda: None)()) except Exception: pass except Exception as e: debug_print(f"ERROR: Failed to initiate TTW workflow: {e}") MessageService.critical( self, "TTW Navigation Failed", f"Failed to navigate to TTW installation screen: {str(e)}" ) def _on_ttw_integration_complete(self, success: bool, ttw_version: str = ""): """Handle completion of TTW integration and show final success dialog Args: success: Whether TTW integration completed successfully ttw_version: Version of TTW that was installed """ try: if not success: MessageService.critical( self, "TTW Integration Failed", "Tale of Two Wastelands integration did not complete successfully." ) return # Navigate back to this screen to show success dialog if self.stacked_widget: self.stacked_widget.setCurrentIndex(4) # Calculate elapsed time from workflow start import time if hasattr(self, '_install_workflow_start_time'): time_taken = int(time.time() - self._install_workflow_start_time) mins, secs = divmod(time_taken, 60) time_str = f"{mins} minutes, {secs} seconds" if mins else f"{secs} seconds" else: time_str = "unknown" # Build success message including TTW installation modlist_name = getattr(self, '_ttw_modlist_name', 'Unknown') game_name = "Fallout New Vegas" # Clear Activity window before showing success dialog self.file_progress_list.clear() # Show enhanced success dialog success_dialog = SuccessDialog( modlist_name=modlist_name, workflow_type="install", time_taken=time_str, game_name=game_name, parent=self ) # Add TTW installation info to dialog if possible if hasattr(success_dialog, 'add_info_line'): success_dialog.add_info_line(f"TTW {ttw_version} integrated successfully") success_dialog.show() except Exception as e: debug_print(f"ERROR: Failed to show final success dialog: {e}") MessageService.critical( self, "Display Error", f"TTW integration completed but failed to show success dialog: {str(e)}" ) def validate_and_start_install(self): import time self._install_workflow_start_time = time.time() debug_print('DEBUG: validate_and_start_install called') # Immediately show "Initialising" status to provide feedback self.progress_indicator.set_status("Initialising...", 0) QApplication.processEvents() # Force UI update # Reload config to pick up any settings changes made in Settings dialog self.config_handler.reload_config() # Check protontricks before proceeding if not self._check_protontricks(): self.progress_indicator.reset() return # Disable all controls during installation (except Cancel) self._disable_controls_during_operation() try: tab_index = self.source_tabs.currentIndex() install_mode = 'online' if tab_index == 1: # .wabbajack File tab modlist = self.file_edit.text().strip() if not modlist or not os.path.isfile(modlist) or not modlist.endswith('.wabbajack'): self._abort_with_message( "warning", "Invalid Modlist", "Please select a valid .wabbajack file." ) return install_mode = 'file' else: # For online modlists, ALWAYS use machine_url from selected_modlist_info # Button text is now the display name (title), NOT the machine URL if not hasattr(self, 'selected_modlist_info') or not self.selected_modlist_info: self._abort_with_message( "warning", "Invalid Modlist", "Modlist information is missing. Please select the modlist again from the gallery." ) return machine_url = self.selected_modlist_info.get('machine_url') if not machine_url: self._abort_with_message( "warning", "Invalid Modlist", "Modlist information is incomplete. Please select the modlist again from the gallery." ) return # CRITICAL: Use machine_url, NOT button text modlist = machine_url install_dir = self.install_dir_edit.text().strip() downloads_dir = self.downloads_dir_edit.text().strip() # Get authentication token (OAuth or API key) with automatic refresh api_key = self.auth_service.ensure_valid_auth() if not api_key: self._abort_with_message( "warning", "Authorisation Required", "Please authorise with Nexus Mods before installing modlists.\n\n" "Click the 'Authorise' button above to log in with OAuth,\n" "or configure an API key in Settings.", safety_level="medium" ) return modlist_name = self.modlist_name_edit.text().strip() missing_fields = [] if not modlist_name: missing_fields.append("Modlist Name") if not install_dir: missing_fields.append("Install Directory") if not downloads_dir: missing_fields.append("Downloads Directory") if missing_fields: self._abort_with_message( "warning", "Missing Required Fields", "Please fill in all required fields before starting the install:\n- " + "\n- ".join(missing_fields) ) return validation_handler = ValidationHandler() from pathlib import Path is_safe, reason = validation_handler.is_safe_install_directory(Path(install_dir)) if not is_safe: dlg = WarningDialog(reason, parent=self) result = dlg.exec() if not result or not dlg.confirmed: self._abort_install_validation() return if not os.path.isdir(install_dir): create = MessageService.question(self, "Create Directory?", f"The install directory does not exist:\n{install_dir}\n\nWould you like to create it?", critical=False # Non-critical, won't steal focus ) if create == QMessageBox.Yes: try: os.makedirs(install_dir, exist_ok=True) except Exception as e: MessageService.critical(self, "Error", f"Failed to create install directory:\n{e}") self._abort_install_validation() return else: self._abort_install_validation() return if not os.path.isdir(downloads_dir): create = MessageService.question(self, "Create Directory?", f"The downloads directory does not exist:\n{downloads_dir}\n\nWould you like to create it?", critical=False # Non-critical, won't steal focus ) if create == QMessageBox.Yes: try: os.makedirs(downloads_dir, exist_ok=True) except Exception as e: MessageService.critical(self, "Error", f"Failed to create downloads directory:\n{e}") self._abort_install_validation() return else: self._abort_install_validation() return # Handle resolution saving resolution = self.resolution_combo.currentText() if resolution and resolution != "Leave unchanged": success = self.resolution_service.save_resolution(resolution) if success: debug_print(f"DEBUG: Resolution saved successfully: {resolution}") else: debug_print("DEBUG: Failed to save resolution") else: # Clear saved resolution if "Leave unchanged" is selected if self.resolution_service.has_saved_resolution(): self.resolution_service.clear_saved_resolution() debug_print("DEBUG: Saved resolution cleared") # Handle parent directory saving self._save_parent_directories(install_dir, downloads_dir) # Detect game type and check support game_type = None game_name = None if install_mode == 'file': # Parse .wabbajack file to get game type from pathlib import Path wabbajack_path = Path(modlist) result = self.wabbajack_parser.parse_wabbajack_game_type(wabbajack_path) if result: if isinstance(result, tuple): game_type, raw_game_type = result # Get display name for the game display_names = { 'skyrim': 'Skyrim', 'fallout4': 'Fallout 4', 'falloutnv': 'Fallout New Vegas', 'oblivion': 'Oblivion', 'starfield': 'Starfield', 'oblivion_remastered': 'Oblivion Remastered', 'enderal': 'Enderal' } if game_type == 'unknown' and raw_game_type: game_name = raw_game_type else: game_name = display_names.get(game_type, game_type) else: game_type = result display_names = { 'skyrim': 'Skyrim', 'fallout4': 'Fallout 4', 'falloutnv': 'Fallout New Vegas', 'oblivion': 'Oblivion', 'starfield': 'Starfield', 'oblivion_remastered': 'Oblivion Remastered', 'enderal': 'Enderal' } game_name = display_names.get(game_type, game_type) else: # For online modlists, try to get game type from selected modlist if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info: game_name = self.selected_modlist_info.get('game', '') debug_print(f"DEBUG: Detected game_name from selected_modlist_info: '{game_name}'") # Map game name to game type game_mapping = { 'skyrim special edition': 'skyrim', 'skyrim': 'skyrim', 'fallout 4': 'fallout4', 'fallout new vegas': 'falloutnv', 'oblivion': 'oblivion', 'starfield': 'starfield', 'oblivion_remastered': 'oblivion_remastered', 'enderal': 'enderal', 'enderal special edition': 'enderal' } game_type = game_mapping.get(game_name.lower()) debug_print(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'") if not game_type: game_type = 'unknown' debug_print(f"DEBUG: Game type not found in mapping, setting to 'unknown'") else: debug_print(f"DEBUG: No selected_modlist_info found") game_type = 'unknown' # Store game type and name for later use self._current_game_type = game_type self._current_game_name = game_name # Check if game is supported debug_print(f"DEBUG: Checking if game_type '{game_type}' is supported") debug_print(f"DEBUG: game_type='{game_type}', game_name='{game_name}'") is_supported = self.wabbajack_parser.is_supported_game(game_type) if game_type else False debug_print(f"DEBUG: is_supported_game('{game_type}') returned: {is_supported}") if game_type and not is_supported: debug_print(f"DEBUG: Game '{game_type}' is not supported, showing dialog") # Show unsupported game dialog dialog = UnsupportedGameDialog(self, game_name) if not dialog.show_dialog(self, game_name): self._abort_install_validation() return self.console.clear() self.process_monitor.clear() # R&D: Reset progress indicator for new installation self.progress_indicator.reset() self.progress_state_manager.reset() self.file_progress_list.clear() self.file_progress_list.start_cpu_tracking() # Start tracking CPU during installation self._premium_notice_shown = False self._premium_failure_active = False self._post_install_active = False self._post_install_current_step = 0 # Activity tab is always visible (tabs handle visibility automatically) # Update button states for installation self.start_btn.setEnabled(False) self.cancel_btn.setVisible(False) self.cancel_install_btn.setVisible(True) # CRITICAL: Final safety check - ensure online modlists use machine_url if install_mode == 'online': if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info: expected_machine_url = self.selected_modlist_info.get('machine_url') if expected_machine_url: modlist = expected_machine_url # Force use machine_url else: self._abort_with_message( "critical", "Installation Error", "Cannot determine modlist machine URL. Please select the modlist again." ) return else: self._abort_with_message( "critical", "Installation Error", "Modlist information is missing. Please select the modlist again from the gallery." ) return debug_print(f'DEBUG: Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, install_mode={install_mode}') self.run_modlist_installer(modlist, install_dir, downloads_dir, api_key, install_mode) except Exception as e: debug_print(f"DEBUG: Exception in validate_and_start_install: {e}") import traceback debug_print(f"DEBUG: Traceback: {traceback.format_exc()}") # Re-enable all controls after exception self._enable_controls_after_operation() self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) debug_print(f"DEBUG: Controls re-enabled in exception handler") def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online'): debug_print('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER') # Rotate log file at start of each workflow run (keep 5 backups) from jackify.backend.handlers.logging_handler import LoggingHandler from pathlib import Path log_handler = LoggingHandler() log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5) # Clear console for fresh installation output self.console.clear() self._safe_append_text("Starting modlist installation with custom progress handling...") # Update UI state for installation self.start_btn.setEnabled(False) self.cancel_btn.setVisible(False) self.cancel_install_btn.setVisible(True) # Create installation thread from PySide6.QtCore import QThread, Signal class InstallationThread(QThread): output_received = Signal(str) progress_received = Signal(str) progress_updated = Signal(object) # R&D: Emits InstallationProgress object installation_finished = Signal(bool, str) premium_required_detected = Signal(str) def __init__(self, modlist, install_dir, downloads_dir, api_key, modlist_name, install_mode='online', progress_state_manager=None): super().__init__() self.modlist = modlist self.install_dir = install_dir self.downloads_dir = downloads_dir self.api_key = api_key self.modlist_name = modlist_name self.install_mode = install_mode self.cancelled = False self.process_manager = None # R&D: Progress state manager for parsing self.progress_state_manager = progress_state_manager self._premium_signal_sent = False def cancel(self): self.cancelled = True if self.process_manager: self.process_manager.cancel() def run(self): try: engine_path = get_jackify_engine_path() # Verify engine exists and is executable if not os.path.exists(engine_path): error_msg = f"Engine not found at: {engine_path}" debug_print(f"DEBUG: {error_msg}") self.installation_finished.emit(False, error_msg) return if not os.access(engine_path, os.X_OK): error_msg = f"Engine is not executable: {engine_path}" debug_print(f"DEBUG: {error_msg}") self.installation_finished.emit(False, error_msg) return debug_print(f"DEBUG: Using engine at: {engine_path}") if self.install_mode == 'file': cmd = [engine_path, "install", "--show-file-progress", "-w", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir] else: cmd = [engine_path, "install", "--show-file-progress", "-m", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir] # Check for debug mode and add --debug flag from jackify.backend.handlers.config_handler import ConfigHandler config_handler = ConfigHandler() debug_mode = config_handler.get('debug_mode', False) if debug_mode: cmd.append('--debug') debug_print("DEBUG: Added --debug flag to jackify-engine command") # CRITICAL: Print the FULL command so we can see exactly what's being passed debug_print(f"DEBUG: FULL Engine command: {' '.join(cmd)}") debug_print(f"DEBUG: modlist value being passed: '{self.modlist}'") # Use clean subprocess environment to prevent AppImage variable inheritance from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env env = get_clean_subprocess_env({'NEXUS_API_KEY': self.api_key}) self.process_manager = ProcessManager(cmd, env=env, text=False) ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]') buffer = b'' last_was_blank = False while True: if self.cancelled: self.cancel() break char = self.process_manager.read_stdout_char() if not char: break buffer += char while b'\n' in buffer or b'\r' in buffer: if b'\r' in buffer and (buffer.index(b'\r') < buffer.index(b'\n') if b'\n' in buffer else True): line, buffer = buffer.split(b'\r', 1) line = ansi_escape.sub(b'', line) decoded = line.decode('utf-8', errors='replace') # Notify when Nexus requires Premium before continuing from jackify.backend.handlers.config_handler import ConfigHandler config_handler = ConfigHandler() debug_mode = config_handler.get('debug_mode', False) if not self._premium_signal_sent and is_non_premium_indicator(decoded): self._premium_signal_sent = True self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required") # R&D: Process through progress parser if self.progress_state_manager: updated = self.progress_state_manager.process_line(decoded) if updated: progress_state = self.progress_state_manager.get_state() # Debug: Log when we detect file progress if progress_state.active_files and debug_mode: debug_print(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}") self.progress_updated.emit(progress_state) # Filter FILE_PROGRESS spam but keep the status line before it if '[FILE_PROGRESS]' in decoded: parts = decoded.split('[FILE_PROGRESS]', 1) if parts[0].strip(): self.progress_received.emit(parts[0].rstrip()) else: # Preserve \r line ending for progress updates self.progress_received.emit(decoded + '\r') elif b'\n' in buffer: line, buffer = buffer.split(b'\n', 1) line = ansi_escape.sub(b'', line) decoded = line.decode('utf-8', errors='replace') # Notify when Nexus requires Premium before continuing if not self._premium_signal_sent and is_non_premium_indicator(decoded): self._premium_signal_sent = True self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required") # R&D: Process through progress parser from jackify.backend.handlers.config_handler import ConfigHandler config_handler = ConfigHandler() debug_mode = config_handler.get('debug_mode', False) if self.progress_state_manager: updated = self.progress_state_manager.process_line(decoded) if updated: progress_state = self.progress_state_manager.get_state() # Debug: Log when we detect file progress if progress_state.active_files and debug_mode: debug_print(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}") self.progress_updated.emit(progress_state) # Filter FILE_PROGRESS spam but keep the status line before it if '[FILE_PROGRESS]' in decoded: parts = decoded.split('[FILE_PROGRESS]', 1) if parts[0].strip(): self.output_received.emit(parts[0].rstrip()) last_was_blank = False continue # Collapse multiple blank lines to one if decoded.strip() == '': if not last_was_blank: self.output_received.emit('\n') last_was_blank = True else: # Preserve \n line ending for normal output self.output_received.emit(decoded + '\n') last_was_blank = False if buffer: line = ansi_escape.sub(b'', buffer) decoded = line.decode('utf-8', errors='replace') # Filter FILE_PROGRESS from final buffer flush too if '[FILE_PROGRESS]' in decoded: parts = decoded.split('[FILE_PROGRESS]', 1) if parts[0].strip(): self.output_received.emit(parts[0].rstrip()) else: self.output_received.emit(decoded) # Wait for process to complete returncode = self.process_manager.wait() # Capture any remaining output after process ends if self.process_manager.proc and self.process_manager.proc.stdout: try: remaining = self.process_manager.proc.stdout.read() if remaining: decoded_remaining = remaining.decode('utf-8', errors='replace') if decoded_remaining.strip(): debug_print(f"DEBUG: Remaining output after process exit: {decoded_remaining[:500]}") # Filter FILE_PROGRESS from remaining output too if '[FILE_PROGRESS]' in decoded_remaining: parts = decoded_remaining.split('[FILE_PROGRESS]', 1) if parts[0].strip(): self.output_received.emit(parts[0].rstrip()) else: self.output_received.emit(decoded_remaining) except Exception as e: debug_print(f"DEBUG: Error reading remaining output: {e}") if self.cancelled: self.installation_finished.emit(False, "Installation cancelled by user") elif returncode == 0: self.installation_finished.emit(True, "Installation completed successfully") else: error_msg = f"Installation failed (exit code {returncode})" debug_print(f"DEBUG: Engine exited with code {returncode}") # Try to get more details from the process if self.process_manager.proc: debug_print(f"DEBUG: Process stderr/stdout may contain error details") self.installation_finished.emit(False, error_msg) except Exception as e: self.installation_finished.emit(False, f"Installation error: {str(e)}") finally: if self.cancelled and self.process_manager: self.process_manager.cancel() # After the InstallationThread class definition, add: self.install_thread = InstallationThread( modlist, install_dir, downloads_dir, api_key, self.modlist_name_edit.text().strip(), install_mode, progress_state_manager=self.progress_state_manager # R&D: Pass progress state manager ) self.install_thread.output_received.connect(self.on_installation_output) self.install_thread.progress_received.connect(self.on_installation_progress) self.install_thread.progress_updated.connect(self.on_progress_updated) # R&D: Connect progress update self.install_thread.installation_finished.connect(self.on_installation_finished) self.install_thread.premium_required_detected.connect(self.on_premium_required_detected) # R&D: Pass progress state manager to thread self.install_thread.progress_state_manager = self.progress_state_manager self.install_thread.start() def on_installation_output(self, message): """Handle regular output from installation thread""" # Filter out internal status messages from user console if message.strip().startswith('[Jackify]'): # Log internal messages to file but don't show in console self._write_to_log_file(message) return # Detect known engine bugs and provide helpful guidance msg_lower = message.lower() if 'destination array was not long enough' in msg_lower or \ ('argumentexception' in msg_lower and 'downloadmachineurl' in msg_lower): # This is a known bug in jackify-engine 0.4.0 during .wabbajack download if not hasattr(self, '_array_error_notified'): self._array_error_notified = True guidance = ( "\n[Jackify] Engine Error Detected: Buffer size issue during .wabbajack download.\n" "[Jackify] This is a known bug in jackify-engine 0.4.0.\n" "[Jackify] Workaround: Delete any partial .wabbajack files in your downloads directory and try again.\n" ) self._safe_append_text(guidance) # R&D: Always write output to console buffer so it's available when user toggles Show Details # The console visibility is controlled by the checkbox, not whether we write to it self._safe_append_text(message) def on_installation_progress(self, progress_message): """ Handle progress messages from installation thread. NOTE: This is called for MOST engine output, not just progress lines! The name is misleading - it's actually the main output path. """ # Always write output to console buffer (same as on_installation_output) self._safe_append_text(progress_message) def on_premium_required_detected(self, engine_line: str): """Handle detection of Nexus Premium requirement.""" if self._premium_notice_shown: return self._premium_notice_shown = True self._premium_failure_active = True user_message = ( "Nexus Mods rejected the automated download because this account is not Premium. " "Jackify currently requires a Nexus Premium membership for automated installs, " "and non-premium support is still planned." ) if engine_line: self._safe_append_text(f"[Jackify] Engine message: {engine_line}") self._safe_append_text("[Jackify] Jackify detected that Nexus Premium is required for this modlist install.") MessageService.critical( self, "Nexus Premium Required", f"{user_message}\n\nDetected engine output:\n{engine_line or 'Buy Nexus Premium to automate this process.'}", safety_level="medium" ) if hasattr(self, 'install_thread') and self.install_thread: self.install_thread.cancel() def on_progress_updated(self, progress_state): """R&D: Handle structured progress updates from parser""" # Calculate proper overall progress during BSA building # During BSA building, file installation is at 100% but BSAs are still being built # Override overall_percent to show BSA building progress instead if progress_state.bsa_building_total > 0 and progress_state.bsa_building_current > 0: bsa_percent = (progress_state.bsa_building_current / progress_state.bsa_building_total) * 100.0 progress_state.overall_percent = min(99.0, bsa_percent) # Cap at 99% until fully complete # Update progress indicator widget self.progress_indicator.update_progress(progress_state) # Only show file progress list if console is not visible (mutually exclusive) console_visible = self.show_details_checkbox.isChecked() # Determine phase display name up front (short/stable label) phase_label = progress_state.get_phase_label() # During installation or extraction phase, show summary counter instead of individual files # This avoids cluttering the UI with hundreds of completed files is_installation_phase = ( progress_state.phase == InstallationPhase.INSTALL or (progress_state.phase_name and 'install' in progress_state.phase_name.lower()) ) is_extraction_phase = ( progress_state.phase == InstallationPhase.EXTRACT or (progress_state.phase_name and 'extract' in progress_state.phase_name.lower()) ) # Detect BSA building phase - check multiple indicators is_bsa_building = False # Check phase name for BSA indicators if progress_state.phase_name: phase_lower = progress_state.phase_name.lower() if 'bsa' in phase_lower or ('building' in phase_lower and progress_state.phase == InstallationPhase.INSTALL): is_bsa_building = True # Check message/status text for BSA building indicators if not is_bsa_building and progress_state.message: msg_lower = progress_state.message.lower() if ('building' in msg_lower or 'writing' in msg_lower or 'verifying' in msg_lower) and '.bsa' in msg_lower: is_bsa_building = True # Check if we have BSA files being processed (even if they're at 100%, they indicate BSA phase) if not is_bsa_building and progress_state.active_files: bsa_files = [f for f in progress_state.active_files if f.filename.lower().endswith('.bsa')] if len(bsa_files) > 0: # If we have any BSA files and we're in INSTALL phase, likely BSA building if progress_state.phase == InstallationPhase.INSTALL: is_bsa_building = True # Also check display text for BSA mentions (fallback) if not is_bsa_building: display_lower = progress_state.display_text.lower() if 'bsa' in display_lower and progress_state.phase == InstallationPhase.INSTALL: is_bsa_building = True now_mono = time.monotonic() if is_bsa_building: self._bsa_hold_deadline = now_mono + 1.5 elif now_mono < self._bsa_hold_deadline: is_bsa_building = True else: self._bsa_hold_deadline = now_mono if is_installation_phase: # During installation, we may have BSA building AND file installation happening # Show both: install summary + any active BSA files # Render loop handles smooth updates - just set target state current_step = progress_state.phase_step from jackify.shared.progress_models import FileProgress, OperationType display_items = [] # Line 1: Always show "Installing Files: X/Y" at the top (no progress bar, no size) if current_step > 0 or progress_state.phase_max_steps > 0: install_line = FileProgress( filename=f"Installing Files: {current_step}/{progress_state.phase_max_steps}", operation=OperationType.INSTALL, percent=0.0, speed=-1.0 ) install_line._no_progress_bar = True # Flag to hide progress bar display_items.append(install_line) # Lines 2+: Show converting textures and BSA files # Extract and categorize active files for f in progress_state.active_files: if f.operation == OperationType.INSTALL: if f.filename.lower().endswith('.bsa') or f.filename.lower().endswith('.ba2'): # BSA: filename.bsa (42/89) - Use state-level BSA counter if progress_state.bsa_building_total > 0: display_filename = f"BSA: {f.filename} ({progress_state.bsa_building_current}/{progress_state.bsa_building_total})" else: display_filename = f"BSA: {f.filename}" display_file = FileProgress( filename=display_filename, operation=f.operation, percent=f.percent, current_size=0, # Don't show size total_size=0, speed=-1.0 # No speed ) display_items.append(display_file) if len(display_items) >= 4: # Max 1 install line + 3 operations break elif f.filename.lower().endswith(('.dds', '.png', '.tga', '.bmp')): # Converting Texture: filename.dds (234/1078) # Use state-level texture counter (more reliable than file-level) if progress_state.texture_conversion_total > 0: display_filename = f"Converting Texture: {f.filename} ({progress_state.texture_conversion_current}/{progress_state.texture_conversion_total})" else: # No texture counter available, just show filename display_filename = f"Converting Texture: {f.filename}" display_file = FileProgress( filename=display_filename, operation=f.operation, percent=f.percent, current_size=0, # Don't show size total_size=0, speed=-1.0 # No speed ) display_items.append(display_file) if len(display_items) >= 4: # Max 1 install line + 3 operations break # Update target state (render loop handles smooth display) # Explicitly pass None for summary_info to clear any stale summary data if display_items: self.file_progress_list.update_files(display_items, current_phase="Installing", summary_info=None) return elif is_extraction_phase: # Show summary info for Extracting phase (step count) # Render loop handles smooth updates - just set target state # Explicitly pass empty list for file_progresses to clear any stale file list current_step = progress_state.phase_step summary_info = { 'current_step': current_step, 'max_steps': progress_state.phase_max_steps, } phase_display_name = phase_label or "Extracting" self.file_progress_list.update_files([], current_phase=phase_display_name, summary_info=summary_info) return elif progress_state.active_files: if self.debug: debug_print(f"DEBUG: Updating file progress list with {len(progress_state.active_files)} files") for fp in progress_state.active_files: debug_print(f"DEBUG: - {fp.filename}: {fp.percent:.1f}% ({fp.operation.value})") # Pass phase label to update header (e.g., "[Activity - Downloading]") # Explicitly clear summary_info when showing file list try: self.file_progress_list.update_files(progress_state.active_files, current_phase=phase_label, summary_info=None) except RuntimeError as e: # Widget was deleted - ignore to prevent coredump if "already deleted" in str(e): if self.debug: debug_print(f"DEBUG: Ignoring widget deletion error: {e}") return raise except Exception as e: # Catch any other exceptions to prevent coredump if self.debug: debug_print(f"DEBUG: Error updating file progress list: {e}") import logging logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True) else: # Show empty state so widget stays visible even when no files are active try: self.file_progress_list.update_files([], current_phase=phase_label) except RuntimeError as e: # Widget was deleted - ignore to prevent coredump if "already deleted" in str(e): return raise except Exception as e: # Catch any other exceptions to prevent coredump import logging logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True) def _on_show_details_toggled(self, checked: bool): """R&D: Toggle console visibility (reuse TTW pattern)""" from PySide6.QtCore import Qt as _Qt self._toggle_console_visibility(_Qt.Checked if checked else _Qt.Unchecked) def _toggle_console_visibility(self, state): """R&D: Toggle console visibility only When "Show Details" is checked: - Show Console (below tabs) - Expand window height When "Show Details" is unchecked: - Hide Console - Collapse window height Note: Activity and Process Monitor tabs are always available via tabs. """ is_checked = (state == Qt.Checked) # Get main window reference (like TTW screen) main_window = None try: from PySide6.QtWidgets import QApplication app = QApplication.instance() if app: main_window = app.activeWindow() # Try to find the actual main window (parent of stacked widget) if self.stacked_widget and self.stacked_widget.parent(): main_window = self.stacked_widget.parent() except Exception: pass # Save geometry on first expand (like TTW screen) if is_checked and main_window and self._saved_geometry is None: try: self._saved_geometry = main_window.geometry() self._saved_min_size = main_window.minimumSize() except Exception: pass if is_checked: # Keep upper section height consistent - don't change it # This prevents buttons from being cut off try: if hasattr(self, 'upper_section_widget') and self.upper_section_widget is not None: # Maintain consistent height - ALWAYS use the stored fixed height # Never recalculate - use the exact same height calculated in showEvent if hasattr(self, '_upper_section_fixed_height') and self._upper_section_fixed_height is not None: self.upper_section_widget.setMaximumHeight(self._upper_section_fixed_height) self.upper_section_widget.setMinimumHeight(self._upper_section_fixed_height) # Lock it # If somehow not stored, it should have been set in showEvent - don't recalculate here self.upper_section_widget.updateGeometry() except Exception: pass # Show console self.console.setVisible(True) self.console.show() self.console.setMinimumHeight(200) self.console.setMaximumHeight(16777215) # Remove height limit try: self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # Set stretch on console in its layout to fill space console_layout = self.console.parent().layout() if console_layout: console_layout.setStretchFactor(console_layout.indexOf(self.console), 1) # Restore spacing when console is visible console_layout.setSpacing(4) except Exception: pass try: # Set spacing in console_and_buttons_layout when console is visible if hasattr(self, 'console_and_buttons_layout'): self.console_and_buttons_layout.setSpacing(4) # Small gap between console and buttons # Set stretch on console_and_buttons_widget to fill space when expanded if hasattr(self, 'console_and_buttons_widget'): self.main_overall_vbox.setStretchFactor(self.console_and_buttons_widget, 1) # Allow expansion when console is visible - remove fixed height constraint self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # Clear fixed height by setting min/max (setFixedHeight sets both, so we override it) self.console_and_buttons_widget.setMinimumHeight(0) self.console_and_buttons_widget.setMaximumHeight(16777215) self.console_and_buttons_widget.updateGeometry() except Exception: pass # Notify parent to expand - let main window handle resizing try: self.resize_request.emit('expand') except Exception: pass else: # Keep upper section height consistent - use same constraint # This prevents buttons from being cut off try: if hasattr(self, 'upper_section_widget') and self.upper_section_widget is not None: # Use the same stored fixed height for consistency # ALWAYS use the stored height - never recalculate to avoid drift if hasattr(self, '_upper_section_fixed_height') and self._upper_section_fixed_height is not None: self.upper_section_widget.setMaximumHeight(self._upper_section_fixed_height) self.upper_section_widget.setMinimumHeight(self._upper_section_fixed_height) # Lock it # If somehow not stored, it should have been set in showEvent - don't recalculate here self.upper_section_widget.updateGeometry() except Exception: pass # Hide console and ensure it takes zero space self.console.setVisible(False) self.console.setMinimumHeight(0) self.console.setMaximumHeight(0) # Use Ignored size policy so it doesn't participate in layout calculations self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) try: # Remove stretch from console in its layout console_layout = self.console.parent().layout() if console_layout: console_layout.setStretchFactor(console_layout.indexOf(self.console), 0) # CRITICAL: Set spacing to 0 when console is hidden to eliminate gap console_layout.setSpacing(0) except Exception: pass try: # CRITICAL: Set spacing to 0 in console_and_buttons_layout when console is hidden if hasattr(self, 'console_and_buttons_layout'): self.console_and_buttons_layout.setSpacing(0) # Remove stretch from console container when collapsed console_container = self.console.parent() if console_container: self.main_overall_vbox.setStretchFactor(console_container, 0) # Also remove stretch from console_and_buttons_widget to prevent large gaps if hasattr(self, 'console_and_buttons_widget'): self.main_overall_vbox.setStretchFactor(self.console_and_buttons_widget, 0) # Use Minimum size policy - takes only the minimum space needed (just buttons) self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Lock height to exactly button row height when collapsed self.console_and_buttons_widget.setFixedHeight(50) # Match button row height exactly # Update geometry to force recalculation self.console_and_buttons_widget.updateGeometry() except Exception: pass # Notify parent to collapse - let main window handle resizing try: self.resize_request.emit('collapse') except Exception: pass def on_installation_finished(self, success, message): """Handle installation completion""" debug_print(f"DEBUG: on_installation_finished called with success={success}, message={message}") # R&D: Clear all progress displays when installation completes self.progress_state_manager.reset() # Clear file list but keep CPU tracking running for configuration phase self.file_progress_list.list_widget.clear() self.file_progress_list._file_items.clear() self.file_progress_list._summary_widget = None self.file_progress_list._transition_label = None self.file_progress_list._last_phase = None if success: # Update progress indicator with completion from jackify.shared.progress_models import InstallationProgress, InstallationPhase final_state = InstallationProgress( phase=InstallationPhase.FINALIZE, phase_name="Installation Complete", overall_percent=100.0 ) self.progress_indicator.update_progress(final_state) if self.show_details_checkbox.isChecked(): self._safe_append_text(f"\nSuccess: {message}") self.process_finished(0, QProcess.NormalExit) # Simulate successful completion else: # Reset to initial state on failure self.progress_indicator.reset() if self._premium_failure_active: message = "Installation stopped because Nexus Premium is required for automated downloads." if self.show_details_checkbox.isChecked(): self._safe_append_text(f"\nError: {message}") self.process_finished(1, QProcess.CrashExit) # Simulate error def process_finished(self, exit_code, exit_status): debug_print(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}") # Reset button states self.start_btn.setEnabled(True) self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) debug_print("DEBUG: Button states reset in process_finished") if exit_code == 0: # Check if this was an unsupported game game_type = getattr(self, '_current_game_type', None) game_name = getattr(self, '_current_game_name', None) if game_type and not self.wabbajack_parser.is_supported_game(game_type): # Show success message for unsupported games without post-install configuration MessageService.information( self, "Modlist Install Complete!", f"Modlist installation completed successfully!\n\n" f"Note: Post-install configuration was skipped for unsupported game type: {game_name or game_type}\n\n" f"You will need to manually configure Steam shortcuts and other post-install steps." ) self._safe_append_text(f"\nModlist installation completed successfully.") self._safe_append_text(f"\nWarning: Post-install configuration skipped for unsupported game: {game_name or game_type}") else: # Check if auto-restart is enabled auto_restart_enabled = hasattr(self, 'auto_restart_checkbox') and self.auto_restart_checkbox.isChecked() if auto_restart_enabled: # Auto-accept Steam restart - proceed without dialog self._safe_append_text("\nAuto-accepting Steam restart (unattended mode enabled)") reply = QMessageBox.Yes # Simulate user clicking Yes else: # Show the normal install complete dialog for supported games reply = MessageService.question( self, "Modlist Install Complete!", "Modlist install complete!\n\nWould you like to add this modlist to Steam and configure it now? Steam will restart, closing any game you have open!", critical=False # Non-critical, won't steal focus ) if reply == QMessageBox.Yes: # --- Create Steam shortcut BEFORE restarting Steam --- # Proceed directly to automated prefix creation self.start_automated_prefix_workflow() else: # User selected "No" - show completion message and keep GUI open self._safe_append_text("\nModlist installation completed successfully!") self._safe_append_text("Note: You can manually configure Steam integration later if needed.") MessageService.information( self, "Installation Complete", "Modlist installation completed successfully!\n\n" "The modlist has been installed but Steam integration was skipped.\n" "You can manually add the modlist to Steam later if desired.", safety_level="medium" ) # Re-enable controls since operation is complete self._enable_controls_after_operation() else: # Check for user cancellation first - check message parameter first, then console if self._premium_failure_active: MessageService.warning( self, "Nexus Premium Required", "Jackify stopped the installation because Nexus Mods reported that this account is not Premium.\n\n" "Automatic installs currently require Nexus Premium. Non-premium support is planned.", safety_level="medium" ) self._safe_append_text("\nInstall stopped: Nexus Premium required.") self._premium_failure_active = False elif hasattr(self, '_cancellation_requested') and self._cancellation_requested: # User explicitly cancelled via cancel button MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low") self._cancellation_requested = False else: # Check console as fallback last_output = self.console.toPlainText() if "cancelled by user" in last_output.lower(): MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low") else: MessageService.critical(self, "Install Failed", "The modlist install failed. Please check the console output for details.") self._safe_append_text(f"\nInstall failed (exit code {exit_code}).") self.console.moveCursor(QTextCursor.End) def _setup_scroll_tracking(self): """Set up scroll tracking for professional auto-scroll behavior""" scrollbar = self.console.verticalScrollBar() scrollbar.sliderPressed.connect(self._on_scrollbar_pressed) scrollbar.sliderReleased.connect(self._on_scrollbar_released) scrollbar.valueChanged.connect(self._on_scrollbar_value_changed) def _on_scrollbar_pressed(self): """User started manually scrolling""" self._user_manually_scrolled = True def _on_scrollbar_released(self): """User finished manually scrolling""" self._user_manually_scrolled = False def _on_scrollbar_value_changed(self): """Track if user is at bottom of scroll area""" scrollbar = self.console.verticalScrollBar() # Use tolerance to account for rounding and rapid updates self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1 # If user manually scrolls to bottom, reset manual scroll flag if self._was_at_bottom and self._user_manually_scrolled: # Small delay to allow user to scroll away if they want from PySide6.QtCore import QTimer QTimer.singleShot(100, self._reset_manual_scroll_if_at_bottom) def _build_post_install_sequence(self): """ Define the ordered steps for post-install (Jackify-managed) operations. These steps represent Jackify's automated Steam integration and configuration workflow that runs AFTER the jackify-engine completes modlist installation. Progress is shown as "X/Y" in the progress banner and Activity window. The post-install steps are: 1. Preparing Steam integration - Initial setup before creating Steam shortcut 2. Creating Steam shortcut - Add modlist to Steam library with proper Proton settings 3. Restarting Steam - Restart Steam to make shortcut visible and create AppID 4. Creating Proton prefix - Launch temporary batch file to initialize Proton prefix 5. Verifying Steam setup - Confirm prefix exists and Proton version is correct 6. Steam integration complete - Steam setup finished successfully 7. Installing Wine components - Install vcrun, dotnet, and other Wine dependencies 8. Applying registry files - Import .reg files for game configuration 9. Installing .NET fixes - Apply .NET framework workarounds if needed 10. Enabling dotfiles - Make hidden config files visible in file manager 11. Setting permissions - Ensure modlist files have correct permissions 12. Backing up configuration - Create backup of ModOrganizer.ini 13. Finalising Jackify configuration - All post-install steps complete """ return [ { 'id': 'prepare', 'label': "Preparing Steam integration", 'keywords': [ "starting automated steam setup", "starting configuration phase", "starting configuration" ], }, { 'id': 'steam_shortcut', 'label': "Creating Steam shortcut", 'keywords': [ "creating steam shortcut", "steam shortcut created successfully" ], }, { 'id': 'steam_restart', 'label': "Restarting Steam", 'keywords': [ "restarting steam", "steam restarted successfully" ], }, { 'id': 'proton_prefix', 'label': "Creating Proton prefix", 'keywords': [ "creating proton prefix", "proton prefix created successfully", "temporary batch file launched", "verifying prefix creation" ], }, { 'id': 'steam_verify', 'label': "Verifying Steam setup", 'keywords': [ "verifying setup", "verifying prefix", "setup verification completed", "detecting actual appid", "steam configuration complete" ], }, { 'id': 'steam_complete', 'label': "Steam integration complete", 'keywords': [ "steam integration complete", "steam integration", "steam configuration complete!" ], }, { 'id': 'wine_components', 'label': "Installing Wine components", 'keywords': [ "installing wine components", "wine components", "vcrun", "dotnet", "running winetricks", ], }, { 'id': 'registry_files', 'label': "Applying registry files", 'keywords': [ "applying registry", "importing registry", ".reg file", "registry files", ], }, { 'id': 'dotnet_fixes', 'label': "Installing .NET fixes", 'keywords': [ "dotnet fix", ".net fix", "installing .net", ], }, { 'id': 'enable_dotfiles', 'label': "Enabling dotfiles", 'keywords': [ "enabling dotfiles", "dotfiles", "hidden files", ], }, { 'id': 'set_permissions', 'label': "Setting permissions", 'keywords': [ "setting permissions", "chmod", "permissions", ], }, { 'id': 'backup_config', 'label': "Backing up configuration", 'keywords': [ "backing up", "modorganizer.ini", "backup", ], }, { 'id': 'config_finalize', 'label': "Finalising Jackify configuration", 'keywords': [ "configuration completed successfully", "configuration complete", "manual steps validation failed", "configuration failed" ], }, ] def _begin_post_install_feedback(self): """Reset trackers and surface post-install progress in collapsed mode.""" self._post_install_active = True self._post_install_current_step = 0 self._post_install_last_label = "Preparing Steam integration" total = max(1, self._post_install_total_steps) self._update_post_install_ui(self._post_install_last_label, 0, total) def _handle_post_install_progress(self, message: str): """Translate backend progress messages into collapsed-mode feedback.""" if not self._post_install_active or not message: return text = message.strip() if not text: return normalized = text.lower() total = max(1, self._post_install_total_steps) matched = False matched_step = None for idx, step in enumerate(self._post_install_sequence, start=1): if any(keyword in normalized for keyword in step['keywords']): matched = True matched_step = idx # Always update to the highest step we've seen (don't go backwards) if idx >= self._post_install_current_step: self._post_install_current_step = idx self._post_install_last_label = step['label'] # CRITICAL: Always use the current step (not the matched step) to ensure consistency # This prevents Activity window showing different step than progress banner self._update_post_install_ui(step['label'], self._post_install_current_step, total, detail=text) break # If no match but we have a current step, update with that step (not a new one) if not matched and self._post_install_current_step > 0: label = self._post_install_last_label or "Post-installation" # CRITICAL: Use _post_install_current_step (not a new step) to keep displays in sync self._update_post_install_ui(label, self._post_install_current_step, total, detail=text) def _strip_timestamp_prefix(self, text: str) -> str: """Remove timestamp prefix like '[00:03:15]' from text.""" import re # Match timestamps like [00:03:15], [01:23:45], etc. timestamp_pattern = r'^\[\d{2}:\d{2}:\d{2}\]\s*' return re.sub(timestamp_pattern, '', text) def _update_post_install_ui(self, label: str, step: int, total: int, detail: Optional[str] = None): """Update progress indicator + activity summary for post-install steps.""" # Use the label as the primary display, but include step info in Activity window display_label = label if detail: # Remove timestamp prefix from detail messages clean_detail = self._strip_timestamp_prefix(detail.strip()) if clean_detail: # For Activity window, show the detail with step counter # But keep label simple for progress banner if clean_detail.lower().startswith(label.lower()): display_label = clean_detail else: display_label = clean_detail total = max(1, total) step_clamped = max(0, min(step, total)) overall_percent = (step_clamped / total) * 100.0 # CRITICAL: Ensure both displays use the SAME step counter # Progress banner uses phase_step/phase_max_steps from progress_state progress_state = InstallationProgress( phase=InstallationPhase.FINALIZE, phase_name=display_label, # This will show in progress banner phase_step=step_clamped, # This creates [step/total] in display_text phase_max_steps=total, overall_percent=overall_percent ) self.progress_indicator.update_progress(progress_state) # Activity window uses summary_info with the SAME step counter summary_info = { 'current_step': step_clamped, # Must match phase_step above 'max_steps': total, # Must match phase_max_steps above } # Use the same label for consistency self.file_progress_list.update_files([], current_phase=display_label, summary_info=summary_info) def _end_post_install_feedback(self, success: bool): """Mark the end of post-install feedback.""" if not self._post_install_active: return total = max(1, self._post_install_total_steps) final_step = total if success else max(0, self._post_install_current_step) label = "Post-installation complete" if success else "Post-installation stopped" self._update_post_install_ui(label, final_step, total) self._post_install_active = False self._post_install_last_label = label def _reset_manual_scroll_if_at_bottom(self): """Reset manual scroll flag if user is still at bottom after delay""" scrollbar = self.console.verticalScrollBar() if scrollbar.value() >= scrollbar.maximum() - 1: self._user_manually_scrolled = False def _safe_append_text(self, text): """ Append text with professional auto-scroll behavior. Handles carriage return (\\r) for in-place updates and newline (\\n) for new lines. """ # Write all messages to log file (including internal messages) self._write_to_log_file(text) # Filter out internal status messages from user console display if text.strip().startswith('[Jackify]'): # Internal messages are logged but not shown in user console return # Check if this is a carriage return update (should replace last line) if '\r' in text and '\n' not in text: # Carriage return - replace last line self._replace_last_console_line(text.replace('\r', '')) return # Handle mixed \r\n or just \n - normal append # Clean up any remaining \r characters clean_text = text.replace('\r', '') scrollbar = self.console.verticalScrollBar() # Check if user was at bottom BEFORE adding text was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance # Add the text self.console.append(clean_text) # Auto-scroll if user was at bottom and hasn't manually scrolled # Re-check bottom state after text addition for better reliability if (was_at_bottom and not self._user_manually_scrolled) or \ (not self._user_manually_scrolled and scrollbar.value() >= scrollbar.maximum() - 2): scrollbar.setValue(scrollbar.maximum()) # Ensure user can still manually scroll up during rapid updates if scrollbar.value() == scrollbar.maximum(): self._was_at_bottom = True def _is_similar_progress_line(self, text): """Check if this line is a similar progress update to the last line""" if not hasattr(self, '_last_console_line') or not self._last_console_line: return False # Don't deduplicate if either line contains important markers important_markers = [ 'complete', 'failed', 'error', 'warning', 'starting', '===', '---', 'SUCCESS', 'FAILED', ] text_lower = text.lower() last_lower = self._last_console_line.lower() for marker in important_markers: if marker.lower() in text_lower or marker.lower() in last_lower: return False # Patterns that indicate this is a progress line that should replace the previous # These are the status lines that update rapidly with changing numbers progress_patterns = [ 'Installing files', 'Extracting files', 'Downloading:', 'Building BSAs', 'Validating', ] # Check if both current and last line contain the same progress pattern # AND the lines are actually different (not exact duplicates) for pattern in progress_patterns: if pattern in text and pattern in self._last_console_line: # Only deduplicate if the numbers/progress changed (not exact duplicate) if text.strip() != self._last_console_line.strip(): return True # Special case: texture conversion status is embedded in Installing files lines # Match lines like "Installing files X/Y (A/B) - Converting textures: N/M" if '- Converting textures:' in text and '- Converting textures:' in self._last_console_line: if text.strip() != self._last_console_line.strip(): return True return False def _replace_last_console_line(self, text): """Replace the last line in the console with new text""" scrollbar = self.console.verticalScrollBar() was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Move cursor to end and select the last line cursor = self.console.textCursor() cursor.movePosition(QTextCursor.End) cursor.select(QTextCursor.LineUnderCursor) cursor.removeSelectedText() cursor.deletePreviousChar() # Remove the newline # Insert the new text self.console.append(text) # Track this line self._last_console_line = text # Restore scroll position if was_at_bottom or not self._user_manually_scrolled: scrollbar.setValue(scrollbar.maximum()) def _write_to_log_file(self, message): """Write message to workflow log file with timestamp""" try: from datetime import datetime timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') with open(self.modlist_log_path, 'a', encoding='utf-8') as f: f.write(f"[{timestamp}] {message}\n") except Exception: # Logging should never break the workflow pass def restart_steam_and_configure(self): """Restart Steam using backend service directly - DECOUPLED FROM CLI""" debug_print("DEBUG: restart_steam_and_configure called - using direct backend service") progress = QProgressDialog("Restarting Steam...", None, 0, 0, self) progress.setWindowTitle("Restarting Steam") progress.setWindowModality(Qt.WindowModal) progress.setMinimumDuration(0) progress.setValue(0) progress.show() def do_restart(): debug_print("DEBUG: do_restart thread started - using direct backend service") try: from jackify.backend.handlers.shortcut_handler import ShortcutHandler # Use backend service directly instead of CLI subprocess shortcut_handler = ShortcutHandler(steamdeck=False) # TODO: Use proper system info debug_print("DEBUG: About to call secure_steam_restart()") success = shortcut_handler.secure_steam_restart() debug_print(f"DEBUG: secure_steam_restart() returned: {success}") out = "Steam restart completed successfully." if success else "Steam restart failed." except Exception as e: debug_print(f"DEBUG: Exception in do_restart: {e}") success = False out = str(e) self.steam_restart_finished.emit(success, out) threading.Thread(target=do_restart, daemon=True).start() self._steam_restart_progress = progress # Store to close later def _on_steam_restart_finished(self, success, out): debug_print("DEBUG: _on_steam_restart_finished called") # Safely cleanup progress dialog on main thread if hasattr(self, '_steam_restart_progress') and self._steam_restart_progress: try: self._steam_restart_progress.close() self._steam_restart_progress.deleteLater() # Use deleteLater() for safer cleanup except Exception as e: debug_print(f"DEBUG: Error closing progress dialog: {e}") finally: self._steam_restart_progress = None # Controls are managed by the proper control management system if success: self._safe_append_text("Steam restarted successfully.") # Force Steam GUI to start after restart # Ensure Steam GUI is visible after restart # start_steam() now uses -foreground, but we'll also try to bring GUI to front debug_print("DEBUG: Ensuring Steam GUI is visible after restart") try: import subprocess import time # Wait a moment for Steam processes to stabilize time.sleep(3) # Try multiple methods to ensure GUI opens # Method 1: steam:// protocol (works if Steam is running) try: subprocess.Popen(['xdg-open', 'steam://open/main'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) debug_print("DEBUG: Issued steam://open/main command") time.sleep(1) except Exception as e: debug_print(f"DEBUG: steam://open/main failed: {e}") # Method 2: Direct steam -foreground command (redundant but ensures GUI) try: subprocess.Popen(['steam', '-foreground'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) debug_print("DEBUG: Issued steam -foreground command") except Exception as e2: debug_print(f"DEBUG: steam -foreground failed: {e2}") except Exception as e: debug_print(f"DEBUG: Error ensuring Steam GUI visibility: {e}") # CRITICAL: Bring Jackify window back to focus after Steam restart # This ensures the user can continue with the installation workflow debug_print("DEBUG: Bringing Jackify window back to focus") try: # Get the main window - use window() to get top-level widget, then find QMainWindow top_level = self.window() main_window = None # Try to find QMainWindow in the widget hierarchy if isinstance(top_level, QMainWindow): main_window = top_level else: # Walk up the parent chain current = self while current: if isinstance(current, QMainWindow): main_window = current break current = current.parent() # Last resort: use top-level widget if not main_window and top_level: main_window = top_level if main_window: # Restore window if minimized if hasattr(main_window, 'isMinimized') and main_window.isMinimized(): main_window.showNormal() # Bring to front and activate - use multiple methods for reliability main_window.raise_() main_window.activateWindow() main_window.show() # Force focus with multiple attempts (some window managers need this) from PySide6.QtCore import QTimer QTimer.singleShot(50, lambda: main_window.activateWindow() if main_window else None) QTimer.singleShot(200, lambda: (main_window.raise_(), main_window.activateWindow()) if main_window else None) QTimer.singleShot(500, lambda: main_window.activateWindow() if main_window else None) debug_print(f"DEBUG: Jackify window brought back to focus (type: {type(main_window).__name__})") else: debug_print("DEBUG: Could not find main window to bring to focus") except Exception as e: debug_print(f"DEBUG: Error bringing Jackify to focus: {e}") # Save context for later use in configuration self._manual_steps_retry_count = 0 self._current_modlist_name = self.modlist_name_edit.text().strip() # Save resolution for later use in configuration resolution = self.resolution_combo.currentText() # Extract resolution properly (e.g., "1280x800" from "1280x800 (Steam Deck)") if resolution != "Leave unchanged": if " (" in resolution: self._current_resolution = resolution.split(" (")[0] else: self._current_resolution = resolution else: self._current_resolution = None # Use automated prefix creation instead of manual steps debug_print("DEBUG: Starting automated prefix creation workflow") self._safe_append_text("Starting automated prefix creation workflow...") self.start_automated_prefix_workflow() else: self._safe_append_text("Failed to restart Steam.\n" + out) MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.") def start_automated_prefix_workflow(self): """Start the automated prefix creation workflow""" # CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog # This ensures Proton version and winetricks settings are current self.config_handler._load_config() # Ensure _current_resolution is always set before starting workflow if not hasattr(self, '_current_resolution') or self._current_resolution is None: resolution = self.resolution_combo.currentText() if hasattr(self, 'resolution_combo') else None # Extract resolution properly (e.g., "1280x800" from "1280x800 (Steam Deck)") if resolution and resolution != "Leave unchanged": if " (" in resolution: self._current_resolution = resolution.split(" (")[0] else: self._current_resolution = resolution else: self._current_resolution = None try: # Disable controls during installation self._disable_controls_during_operation() modlist_name = self.modlist_name_edit.text().strip() install_dir = self.install_dir_edit.text().strip() final_exe_path = os.path.join(install_dir, "ModOrganizer.exe") if not os.path.exists(final_exe_path): # Check if this is Somnium specifically (uses files/ subdirectory) modlist_name_lower = modlist_name.lower() if "somnium" in modlist_name_lower: somnium_exe_path = os.path.join(install_dir, "files", "ModOrganizer.exe") if os.path.exists(somnium_exe_path): final_exe_path = somnium_exe_path self._safe_append_text(f"Detected Somnium modlist - will proceed with automated setup") # Show Somnium guidance popup after automated workflow completes self._show_somnium_guidance = True self._somnium_install_dir = install_dir else: self._safe_append_text(f"ERROR: Somnium ModOrganizer.exe not found at {somnium_exe_path}") MessageService.critical(self, "Somnium ModOrganizer.exe Not Found", f"Expected Somnium ModOrganizer.exe not found at:\n{somnium_exe_path}\n\nCannot proceed with automated setup.") return else: self._safe_append_text(f"ERROR: ModOrganizer.exe not found at {final_exe_path}") MessageService.critical(self, "ModOrganizer.exe Not Found", f"ModOrganizer.exe not found at:\n{final_exe_path}\n\nCannot proceed with automated setup.") return self._begin_post_install_feedback() # Run automated prefix creation in separate thread from PySide6.QtCore import QThread, Signal class AutomatedPrefixThread(QThread): finished = Signal(bool, str, str, str) # success, prefix_path, appid (as string), last_timestamp progress = Signal(str) # progress messages error = Signal(str) # error messages show_progress_dialog = Signal(str) # show progress dialog with message hide_progress_dialog = Signal() # hide progress dialog conflict_detected = Signal(list) # conflicts list def __init__(self, modlist_name, install_dir, final_exe_path): super().__init__() self.modlist_name = modlist_name self.install_dir = install_dir self.final_exe_path = final_exe_path def run(self): try: from jackify.backend.services.automated_prefix_service import AutomatedPrefixService def progress_callback(message): self.progress.emit(message) # Show progress dialog during Steam restart if "Steam restarted successfully" in message: self.hide_progress_dialog.emit() elif "Restarting Steam..." in message: self.show_progress_dialog.emit("Restarting Steam...") prefix_service = AutomatedPrefixService() # Determine Steam Deck once and pass through the workflow try: import os _is_steamdeck = False if os.path.exists('/etc/os-release'): with open('/etc/os-release') as f: if 'steamdeck' in f.read().lower(): _is_steamdeck = True except Exception: _is_steamdeck = False result = prefix_service.run_working_workflow( self.modlist_name, self.install_dir, self.final_exe_path, progress_callback, steamdeck=_is_steamdeck ) # Handle the result - check for conflicts if isinstance(result, tuple) and len(result) == 4: if result[0] == "CONFLICT": # Conflict detected - emit signal to main GUI conflicts = result[1] self.hide_progress_dialog.emit() self.conflict_detected.emit(conflicts) return else: # Normal result with timestamp success, prefix_path, new_appid, last_timestamp = result elif isinstance(result, tuple) and len(result) == 3: # Fallback for old format (backward compatibility) if result[0] == "CONFLICT": # Conflict detected - emit signal to main GUI conflicts = result[1] self.hide_progress_dialog.emit() self.conflict_detected.emit(conflicts) return else: # Normal result (old format) success, prefix_path, new_appid = result last_timestamp = None else: # Handle non-tuple result success = result prefix_path = "" new_appid = "0" last_timestamp = None # Ensure progress dialog is hidden when workflow completes self.hide_progress_dialog.emit() self.finished.emit(success, prefix_path or "", str(new_appid) if new_appid else "0", last_timestamp) except Exception as e: # Ensure progress dialog is hidden on error self.hide_progress_dialog.emit() self.error.emit(str(e)) # Create and start thread self.prefix_thread = AutomatedPrefixThread(modlist_name, install_dir, final_exe_path) self.prefix_thread.finished.connect(self.on_automated_prefix_finished) self.prefix_thread.error.connect(self.on_automated_prefix_error) self.prefix_thread.progress.connect(self.on_automated_prefix_progress) self.prefix_thread.show_progress_dialog.connect(self.show_steam_restart_progress) self.prefix_thread.hide_progress_dialog.connect(self.hide_steam_restart_progress) self.prefix_thread.conflict_detected.connect(self.show_shortcut_conflict_dialog) self.prefix_thread.start() except Exception as e: debug_print(f"DEBUG: Exception in start_automated_prefix_workflow: {e}") import traceback debug_print(f"DEBUG: Traceback: {traceback.format_exc()}") self._safe_append_text(f"ERROR: Failed to start automated workflow: {e}") # Re-enable controls on exception self._enable_controls_after_operation() def on_automated_prefix_finished(self, success, prefix_path, new_appid_str, last_timestamp=None): """Handle completion of automated prefix creation""" try: if success: debug_print(f"SUCCESS: Automated prefix creation completed!") debug_print(f"Prefix created at: {prefix_path}") if new_appid_str and new_appid_str != "0": debug_print(f"AppID: {new_appid_str}") # Convert string AppID back to integer for configuration new_appid = int(new_appid_str) if new_appid_str and new_appid_str != "0" else None # Continue with configuration using the new AppID and timestamp modlist_name = self.modlist_name_edit.text().strip() install_dir = self.install_dir_edit.text().strip() self.continue_configuration_after_automated_prefix(new_appid, modlist_name, install_dir, last_timestamp) else: self._safe_append_text(f"ERROR: Automated prefix creation failed") self._safe_append_text("Please check the logs for details") MessageService.critical(self, "Automated Setup Failed", "Automated prefix creation failed. Please check the console output for details.") # Re-enable controls on failure self._enable_controls_after_operation() self._end_post_install_feedback(success=False) finally: # Always ensure controls are re-enabled when workflow truly completes pass def on_automated_prefix_error(self, error_msg): """Handle error in automated prefix creation""" self._safe_append_text(f"ERROR: Error during automated prefix creation: {error_msg}") MessageService.critical(self, "Automated Setup Error", f"Error during automated prefix creation: {error_msg}") # Re-enable controls on error self._enable_controls_after_operation() self._end_post_install_feedback(success=False) def on_automated_prefix_progress(self, progress_msg): """Handle progress updates from automated prefix creation""" self._safe_append_text(progress_msg) self._handle_post_install_progress(progress_msg) def on_configuration_progress(self, progress_msg): """Handle progress updates from modlist configuration""" self._safe_append_text(progress_msg) self._handle_post_install_progress(progress_msg) def show_steam_restart_progress(self, message): """Show Steam restart progress dialog""" from PySide6.QtWidgets import QProgressDialog from PySide6.QtCore import Qt self.steam_restart_progress = QProgressDialog(message, None, 0, 0, self) self.steam_restart_progress.setWindowTitle("Restarting Steam") self.steam_restart_progress.setWindowModality(Qt.WindowModal) self.steam_restart_progress.setMinimumDuration(0) self.steam_restart_progress.setValue(0) self.steam_restart_progress.show() def hide_steam_restart_progress(self): """Hide Steam restart progress dialog""" if hasattr(self, 'steam_restart_progress') and self.steam_restart_progress: try: self.steam_restart_progress.close() self.steam_restart_progress.deleteLater() except Exception: pass finally: self.steam_restart_progress = None # Controls are managed by the proper control management system def on_configuration_complete(self, success, message, modlist_name): """Handle configuration completion on main thread""" try: # Stop CPU tracking now that everything is complete self.file_progress_list.stop_cpu_tracking() # Re-enable controls now that installation/configuration is complete self._enable_controls_after_operation() self._end_post_install_feedback(success) if success: # Check if we need to show Somnium guidance if self._show_somnium_guidance: self._show_somnium_post_install_guidance() # Show celebration SuccessDialog after the entire workflow from ..dialogs import SuccessDialog import time if not hasattr(self, '_install_workflow_start_time'): self._install_workflow_start_time = time.time() time_taken = int(time.time() - self._install_workflow_start_time) mins, secs = divmod(time_taken, 60) time_str = f"{mins} minutes, {secs} seconds" if mins else f"{secs} seconds" display_names = { 'skyrim': 'Skyrim', 'fallout4': 'Fallout 4', 'falloutnv': 'Fallout New Vegas', 'oblivion': 'Oblivion', 'starfield': 'Starfield', 'oblivion_remastered': 'Oblivion Remastered', 'enderal': 'Enderal' } game_name = display_names.get(self._current_game_type, self._current_game_name) # Check for TTW eligibility before showing final success dialog install_dir = self.install_dir_edit.text().strip() if self._check_ttw_eligibility(modlist_name, self._current_game_type, install_dir): # Offer TTW installation reply = MessageService.question( self, "Install TTW?", f"{modlist_name} requires Tale of Two Wastelands!\n\n" "Would you like to install TTW now?\n\n" "This will:\n" "• Guide you through TTW installation\n" "• Attempt to integrate TTW into your modlist automatically\n" "• Configure load order if integration is supported\n\n" "Note: Automatic integration works for some modlists (like Begin Again). " "Other modlists may require manual TTW setup. " "TTW installation can take a while.\n\n" "You can also install TTW later from Additional Tasks & Tools.", critical=False, safety_level="medium" ) if reply == QMessageBox.Yes: # Navigate to TTW screen self._initiate_ttw_workflow(modlist_name, install_dir) return # Don't show success dialog yet, will show after TTW completes # Clear Activity window before showing success dialog self.file_progress_list.clear() # Show normal success dialog success_dialog = SuccessDialog( modlist_name=modlist_name, workflow_type="install", time_taken=time_str, game_name=game_name, parent=self ) success_dialog.show() elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3: # Max retries reached - show failure message MessageService.critical(self, "Manual Steps Failed", "Manual steps validation failed after multiple attempts.") else: # Configuration failed for other reasons MessageService.critical(self, "Configuration Failed", "Post-install configuration failed. Please check the console output.") except Exception as e: # Ensure controls are re-enabled even on unexpected errors self._enable_controls_after_operation() raise # Clean up thread if hasattr(self, 'config_thread') and self.config_thread is not None: # Disconnect all signals to prevent "Internal C++ object already deleted" errors try: self.config_thread.progress_update.disconnect() self.config_thread.configuration_complete.disconnect() self.config_thread.error_occurred.disconnect() except: pass # Ignore errors if already disconnected if self.config_thread.isRunning(): self.config_thread.quit() self.config_thread.wait(5000) # Wait up to 5 seconds self.config_thread.deleteLater() self.config_thread = None def on_configuration_error(self, error_message): """Handle configuration error on main thread""" self._safe_append_text(f"Configuration failed with error: {error_message}") MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}") # Re-enable all controls on error self._enable_controls_after_operation() # Clean up thread if hasattr(self, 'config_thread') and self.config_thread is not None: # Disconnect all signals to prevent "Internal C++ object already deleted" errors try: self.config_thread.progress_update.disconnect() self.config_thread.configuration_complete.disconnect() self.config_thread.error_occurred.disconnect() except: pass # Ignore errors if already disconnected if self.config_thread.isRunning(): self.config_thread.quit() self.config_thread.wait(5000) # Wait up to 5 seconds self.config_thread.deleteLater() self.config_thread = None def show_manual_steps_dialog(self, extra_warning=""): modlist_name = self.modlist_name_edit.text().strip() or "your modlist" msg = ( f"Manual Proton Setup Required for {modlist_name}
" "After Steam restarts, complete the following steps in Steam:
" f"1. Locate the '{modlist_name}' entry in your Steam Library
" "2. Right-click and select 'Properties'
" "3. Switch to the 'Compatibility' tab
" "4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'
" "5. Select 'Proton - Experimental' from the dropdown menu
" "6. Close the Properties window
" f"7. Launch '{modlist_name}' from your Steam Library
" "8. Wait for Mod Organizer 2 to fully open
" "9. Once Mod Organizer has fully loaded, CLOSE IT completely and return here
" "
Once you have completed ALL the steps above, click OK to continue." f"{extra_warning}" ) reply = MessageService.question(self, "Manual Steps Required", msg, safety_level="medium") if reply == QMessageBox.Yes: self.validate_manual_steps_completion() else: # User clicked Cancel or closed the dialog - cancel the workflow self._safe_append_text("\nšŸ›‘ Manual steps cancelled by user. Workflow stopped.") # Re-enable all controls when workflow is cancelled self._enable_controls_after_operation() self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) def _get_mo2_path(self, install_dir, modlist_name): """Get ModOrganizer.exe path, handling Somnium's non-standard structure""" mo2_exe_path = os.path.join(install_dir, "ModOrganizer.exe") if not os.path.exists(mo2_exe_path) and "somnium" in modlist_name.lower(): somnium_path = os.path.join(install_dir, "files", "ModOrganizer.exe") if os.path.exists(somnium_path): mo2_exe_path = somnium_path return mo2_exe_path def validate_manual_steps_completion(self): """Validate that manual steps were actually completed and handle retry logic""" modlist_name = self.modlist_name_edit.text().strip() install_dir = self.install_dir_edit.text().strip() mo2_exe_path = self._get_mo2_path(install_dir, modlist_name) # Add delay to allow Steam filesystem updates to complete self._safe_append_text("Waiting for Steam filesystem updates to complete...") import time time.sleep(2) # CRITICAL: Re-detect the AppID after Steam restart and manual steps # Steam assigns a NEW AppID during restart, different from the one we initially created self._safe_append_text(f"Re-detecting AppID for shortcut '{modlist_name}' after Steam restart...") from jackify.backend.handlers.shortcut_handler import ShortcutHandler from jackify.backend.services.platform_detection_service import PlatformDetectionService platform_service = PlatformDetectionService.get_instance() shortcut_handler = ShortcutHandler(steamdeck=platform_service.is_steamdeck) current_appid = shortcut_handler.get_appid_for_shortcut(modlist_name, mo2_exe_path) if not current_appid or not current_appid.isdigit(): self._safe_append_text(f"Error: Could not find Steam-assigned AppID for shortcut '{modlist_name}'") self._safe_append_text("Error: This usually means the shortcut was not launched from Steam") self._safe_append_text("Suggestion: Check that Steam is running and shortcuts are visible in library") self.handle_validation_failure("Could not find Steam shortcut") return self._safe_append_text(f"Found Steam-assigned AppID: {current_appid}") self._safe_append_text(f"Validating manual steps completion for AppID: {current_appid}") # Check 1: Proton version proton_ok = False try: from jackify.backend.handlers.modlist_handler import ModlistHandler from jackify.backend.handlers.path_handler import PathHandler # Initialize ModlistHandler with correct parameters path_handler = PathHandler() # Use centralized Steam Deck detection from jackify.backend.services.platform_detection_service import PlatformDetectionService platform_service = PlatformDetectionService.get_instance() modlist_handler = ModlistHandler(steamdeck=platform_service.is_steamdeck, verbose=False) # Set required properties manually after initialization modlist_handler.modlist_dir = install_dir modlist_handler.appid = current_appid modlist_handler.game_var = "skyrimspecialedition" # Default for now # Set compat_data_path for Proton detection compat_data_path_str = path_handler.find_compat_data(current_appid) if compat_data_path_str: from pathlib import Path modlist_handler.compat_data_path = Path(compat_data_path_str) # Check Proton version self._safe_append_text(f"Attempting to detect Proton version for AppID {current_appid}...") if modlist_handler._detect_proton_version(): self._safe_append_text(f"Raw detected Proton version: '{modlist_handler.proton_ver}'") if modlist_handler.proton_ver and 'experimental' in modlist_handler.proton_ver.lower(): proton_ok = True self._safe_append_text(f"Proton version validated: {modlist_handler.proton_ver}") else: self._safe_append_text(f"Error: Wrong Proton version detected: '{modlist_handler.proton_ver}' (expected 'experimental' in name)") else: self._safe_append_text("Error: Could not detect Proton version from any source") except Exception as e: self._safe_append_text(f"Error checking Proton version: {e}") proton_ok = False # Check 2: Compatdata directory exists compatdata_ok = False try: from jackify.backend.handlers.path_handler import PathHandler path_handler = PathHandler() self._safe_append_text(f"Searching for compatdata directory for AppID {current_appid}...") self._safe_append_text("Checking standard Steam locations and Flatpak Steam...") prefix_path_str = path_handler.find_compat_data(current_appid) self._safe_append_text(f"Compatdata search result: '{prefix_path_str}'") if prefix_path_str and os.path.isdir(prefix_path_str): compatdata_ok = True self._safe_append_text(f"Compatdata directory found: {prefix_path_str}") else: if prefix_path_str: self._safe_append_text(f"Error: Path exists but is not a directory: {prefix_path_str}") else: self._safe_append_text(f"Error: No compatdata directory found for AppID {current_appid}") self._safe_append_text("Suggestion: Ensure you launched the shortcut from Steam at least once") self._safe_append_text("Suggestion: Check if Steam is using Flatpak (different file paths)") except Exception as e: self._safe_append_text(f"Error checking compatdata: {e}") compatdata_ok = False # Handle validation results if proton_ok and compatdata_ok: self._safe_append_text("Manual steps validation passed!") self._safe_append_text("Continuing configuration with updated AppID...") # Continue configuration with the corrected AppID and context self.continue_configuration_after_manual_steps(current_appid, modlist_name, install_dir) else: # Validation failed - handle retry logic missing_items = [] if not proton_ok: missing_items.append("• Proton - Experimental not set") if not compatdata_ok: missing_items.append("• Shortcut not launched from Steam (no compatdata)") missing_text = "\n".join(missing_items) self._safe_append_text(f"Manual steps validation failed:\n{missing_text}") self.handle_validation_failure(missing_text) def show_shortcut_conflict_dialog(self, conflicts): """Show dialog to resolve shortcut name conflicts""" conflict_names = [c['name'] for c in conflicts] conflict_info = f"Found existing Steam shortcut: '{conflict_names[0]}'" modlist_name = self.modlist_name_edit.text().strip() # Create dialog with Jackify styling from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout from PySide6.QtCore import Qt dialog = QDialog(self) dialog.setWindowTitle("Steam Shortcut Conflict") dialog.setModal(True) dialog.resize(450, 180) # Apply Jackify dark theme styling dialog.setStyleSheet(""" QDialog { background-color: #2b2b2b; color: #ffffff; } QLabel { color: #ffffff; font-size: 14px; padding: 10px 0px; } QLineEdit { background-color: #404040; color: #ffffff; border: 2px solid #555555; border-radius: 4px; padding: 8px; font-size: 14px; selection-background-color: #3fd0ea; } QLineEdit:focus { border-color: #3fd0ea; } QPushButton { background-color: #404040; color: #ffffff; border: 2px solid #555555; border-radius: 4px; padding: 8px 16px; font-size: 14px; min-width: 120px; } QPushButton:hover { background-color: #505050; border-color: #3fd0ea; } QPushButton:pressed { background-color: #303030; } """) layout = QVBoxLayout(dialog) layout.setContentsMargins(20, 20, 20, 20) layout.setSpacing(15) # Conflict message conflict_label = QLabel(f"{conflict_info}\n\nPlease choose a different name for your shortcut:") layout.addWidget(conflict_label) # Text input for new name name_input = QLineEdit(modlist_name) name_input.selectAll() layout.addWidget(name_input) # Buttons button_layout = QHBoxLayout() button_layout.setSpacing(10) create_button = QPushButton("Create with New Name") cancel_button = QPushButton("Cancel") button_layout.addStretch() button_layout.addWidget(cancel_button) button_layout.addWidget(create_button) layout.addLayout(button_layout) # Connect signals def on_create(): new_name = name_input.text().strip() if new_name and new_name != modlist_name: dialog.accept() # Retry workflow with new name self.retry_automated_workflow_with_new_name(new_name) elif new_name == modlist_name: # Same name - show warning from jackify.frontends.gui.services.message_service import MessageService MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.") else: # Empty name from jackify.frontends.gui.services.message_service import MessageService MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.") def on_cancel(): dialog.reject() self._safe_append_text("Shortcut creation cancelled by user") create_button.clicked.connect(on_create) cancel_button.clicked.connect(on_cancel) # Make Enter key work name_input.returnPressed.connect(on_create) dialog.exec() def retry_automated_workflow_with_new_name(self, new_name): """Retry the automated workflow with a new shortcut name""" # Update the modlist name field temporarily original_name = self.modlist_name_edit.text() self.modlist_name_edit.setText(new_name) # Restart the automated workflow self._safe_append_text(f"Retrying with new shortcut name: '{new_name}'") self.start_automated_prefix_workflow() def continue_configuration_after_automated_prefix(self, new_appid, modlist_name, install_dir, last_timestamp=None): """Continue the configuration process with the new AppID after automated prefix creation""" # Headers are now shown at start of Steam Integration # No need to show them again here debug_print("Configuration phase continues after Steam Integration") debug_print(f"continue_configuration_after_automated_prefix called with appid: {new_appid}") try: # Update the context with the new AppID (same format as manual steps) updated_context = { 'name': modlist_name, 'path': install_dir, 'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name), 'modlist_value': None, 'modlist_source': None, 'resolution': getattr(self, '_current_resolution', None), 'skip_confirmation': True, 'manual_steps_completed': True, # Mark as completed since automated prefix is done 'appid': new_appid, # Use the NEW AppID from automated prefix creation 'game_name': self.context.get('game_name', 'Skyrim Special Edition') if hasattr(self, 'context') else 'Skyrim Special Edition' } self.context = updated_context # Ensure context is always set debug_print(f"Updated context with new AppID: {new_appid}") # Get Steam Deck detection once and pass to ConfigThread from jackify.backend.services.platform_detection_service import PlatformDetectionService platform_service = PlatformDetectionService.get_instance() is_steamdeck = platform_service.is_steamdeck # Create new config thread with updated context class ConfigThread(QThread): progress_update = Signal(str) configuration_complete = Signal(bool, str, str) error_occurred = Signal(str) def __init__(self, context, is_steamdeck): super().__init__() self.context = context self.is_steamdeck = is_steamdeck def run(self): try: from jackify.backend.services.modlist_service import ModlistService from jackify.backend.models.configuration import SystemInfo from jackify.backend.models.modlist import ModlistContext from pathlib import Path # Initialize backend service with passed Steam Deck detection system_info = SystemInfo(is_steamdeck=self.is_steamdeck) modlist_service = ModlistService(system_info) # Convert context to ModlistContext for service modlist_context = ModlistContext( name=self.context['name'], install_dir=Path(self.context['path']), download_dir=Path(self.context['path']).parent / 'Downloads', # Default game_type='skyrim', # Default for now nexus_api_key='', # Not needed for configuration modlist_value=self.context.get('modlist_value'), modlist_source=self.context.get('modlist_source', 'identifier'), resolution=self.context.get('resolution'), skip_confirmation=True, engine_installed=True # Skip path manipulation for engine workflows ) # Add app_id to context modlist_context.app_id = self.context['appid'] # Define callbacks def progress_callback(message): self.progress_update.emit(message) def completion_callback(success, message, modlist_name): self.configuration_complete.emit(success, message, modlist_name) def manual_steps_callback(modlist_name, retry_count): # This shouldn't happen since automated prefix creation is complete self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}") # Call the service method for post-Steam configuration result = modlist_service.configure_modlist_post_steam( context=modlist_context, progress_callback=progress_callback, manual_steps_callback=manual_steps_callback, completion_callback=completion_callback ) if not result: self.progress_update.emit("Configuration failed to start") self.error_occurred.emit("Configuration failed to start") except Exception as e: self.error_occurred.emit(str(e)) # Start configuration thread self.config_thread = ConfigThread(updated_context, is_steamdeck) self.config_thread.progress_update.connect(self.on_configuration_progress) self.config_thread.configuration_complete.connect(self.on_configuration_complete) self.config_thread.error_occurred.connect(self.on_configuration_error) self.config_thread.start() except Exception as e: self._safe_append_text(f"Error continuing configuration: {e}") import traceback self._safe_append_text(f"Full traceback: {traceback.format_exc()}") self.on_configuration_error(str(e)) def continue_configuration_after_manual_steps(self, new_appid, modlist_name, install_dir): """Continue the configuration process with the corrected AppID after manual steps validation""" try: # Update the context with the new AppID updated_context = { 'name': modlist_name, 'path': install_dir, 'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name), 'modlist_value': None, 'modlist_source': None, 'resolution': getattr(self, '_current_resolution', None), 'skip_confirmation': True, 'manual_steps_completed': True, # Mark as completed 'appid': new_appid # Use the NEW AppID from Steam } debug_print(f"Updated context with new AppID: {new_appid}") # Clean up old thread if exists and wait for it to finish if hasattr(self, 'config_thread') and self.config_thread is not None: # Disconnect all signals to prevent "Internal C++ object already deleted" errors try: self.config_thread.progress_update.disconnect() self.config_thread.configuration_complete.disconnect() self.config_thread.error_occurred.disconnect() except: pass # Ignore errors if already disconnected if self.config_thread.isRunning(): self.config_thread.quit() self.config_thread.wait(5000) # Wait up to 5 seconds self.config_thread.deleteLater() self.config_thread = None # Start new config thread self.config_thread = self._create_config_thread(updated_context) self.config_thread.progress_update.connect(self.on_configuration_progress) self.config_thread.configuration_complete.connect(self.on_configuration_complete) self.config_thread.error_occurred.connect(self.on_configuration_error) self.config_thread.start() except Exception as e: self._safe_append_text(f"Error continuing configuration: {e}") self.on_configuration_error(str(e)) def _create_config_thread(self, context): """Create a new ConfigThread with proper lifecycle management""" from PySide6.QtCore import QThread, Signal # Get Steam Deck detection once from jackify.backend.services.platform_detection_service import PlatformDetectionService platform_service = PlatformDetectionService.get_instance() is_steamdeck = platform_service.is_steamdeck class ConfigThread(QThread): progress_update = Signal(str) configuration_complete = Signal(bool, str, str) error_occurred = Signal(str) def __init__(self, context, is_steamdeck, parent=None): super().__init__(parent) self.context = context self.is_steamdeck = is_steamdeck def run(self): try: from jackify.backend.models.configuration import SystemInfo from jackify.backend.services.modlist_service import ModlistService from jackify.backend.models.modlist import ModlistContext from pathlib import Path # Initialize backend service with passed Steam Deck detection system_info = SystemInfo(is_steamdeck=self.is_steamdeck) modlist_service = ModlistService(system_info) # Convert context to ModlistContext for service modlist_context = ModlistContext( name=self.context['name'], install_dir=Path(self.context['path']), download_dir=Path(self.context['path']).parent / 'Downloads', # Default game_type='skyrim', # Default for now nexus_api_key='', # Not needed for configuration modlist_value=self.context.get('modlist_value', ''), modlist_source=self.context.get('modlist_source', 'identifier'), resolution=self.context.get('resolution'), # Pass resolution from GUI skip_confirmation=True, engine_installed=True # Skip path manipulation for engine workflows ) # Add app_id to context if 'appid' in self.context: modlist_context.app_id = self.context['appid'] # Define callbacks def progress_callback(message): self.progress_update.emit(message) def completion_callback(success, message, modlist_name): self.configuration_complete.emit(success, message, modlist_name) def manual_steps_callback(modlist_name, retry_count): # This shouldn't happen since manual steps should be done self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}") # Call the new service method for post-Steam configuration result = modlist_service.configure_modlist_post_steam( context=modlist_context, progress_callback=progress_callback, manual_steps_callback=manual_steps_callback, completion_callback=completion_callback ) if not result: self.progress_update.emit("WARNING: configure_modlist_post_steam returned False") except Exception as e: import traceback error_details = f"Error in configuration: {e}\nTraceback: {traceback.format_exc()}" self.progress_update.emit(f"DEBUG: {error_details}") self.error_occurred.emit(str(e)) return ConfigThread(context, is_steamdeck, parent=self) def handle_validation_failure(self, missing_text): """Handle failed validation with retry logic""" self._manual_steps_retry_count += 1 if self._manual_steps_retry_count < 3: # Show retry dialog with increasingly detailed guidance retry_guidance = "" if self._manual_steps_retry_count == 1: retry_guidance = "\n\nTip: Make sure Steam is fully restarted before trying again." elif self._manual_steps_retry_count == 2: retry_guidance = "\n\nTip: If using Flatpak Steam, ensure compatdata is being created in the correct location." MessageService.critical(self, "Manual Steps Incomplete", f"Manual steps validation failed:\n\n{missing_text}\n\n" f"Please complete the missing steps and try again.{retry_guidance}") # Show manual steps dialog again extra_warning = "" if self._manual_steps_retry_count >= 2: extra_warning = "
It looks like you have not completed the manual steps yet. Please try again." self.show_manual_steps_dialog(extra_warning) else: # Max retries reached MessageService.critical(self, "Manual Steps Failed", "Manual steps validation failed after multiple attempts.\n\n" "Common issues:\n" "• Steam not fully restarted\n" "• Shortcut not launched from Steam\n" "• Flatpak Steam using different file paths\n" "• Proton - Experimental not selected") self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self._current_modlist_name) def show_next_steps_dialog(self, message): # EXACT LEGACY show_next_steps_dialog from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout, QApplication dlg = QDialog(self) dlg.setWindowTitle("Next Steps") dlg.setModal(True) layout = QVBoxLayout(dlg) label = QLabel(message) label.setWordWrap(True) layout.addWidget(label) btn_row = QHBoxLayout() btn_return = QPushButton("Return") btn_exit = QPushButton("Exit") btn_row.addWidget(btn_return) btn_row.addWidget(btn_exit) layout.addLayout(btn_row) def on_return(): dlg.accept() if self.stacked_widget: self.stacked_widget.setCurrentIndex(0) # Main menu def on_exit(): QApplication.quit() btn_return.clicked.connect(on_return) btn_exit.clicked.connect(on_exit) dlg.exec() def cleanup_processes(self): """Clean up any running processes when the window closes or is cancelled""" debug_print("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes") # Clean up InstallationThread if running if hasattr(self, 'install_thread') and self.install_thread.isRunning(): debug_print("DEBUG: Cancelling running InstallationThread") self.install_thread.cancel() self.install_thread.wait(3000) # Wait up to 3 seconds if self.install_thread.isRunning(): self.install_thread.terminate() # Clean up other threads threads = [ 'prefix_thread', 'config_thread', 'fetch_thread' ] for thread_name in threads: if hasattr(self, thread_name): thread = getattr(self, thread_name) if thread and thread.isRunning(): debug_print(f"DEBUG: Terminating {thread_name}") thread.terminate() thread.wait(1000) # Wait up to 1 second def cancel_installation(self): """Cancel the currently running installation""" reply = MessageService.question( self, "Cancel Installation", "Are you sure you want to cancel the installation?", critical=False # Non-critical, won't steal focus ) if reply == QMessageBox.Yes: self._safe_append_text("\nšŸ›‘ Cancelling installation...") # Set flag so we can detect cancellation reliably self._cancellation_requested = True try: # Clear Active Files window and reset progress indicator if hasattr(self, 'file_progress_list'): self.file_progress_list.clear() if hasattr(self, 'progress_indicator'): self.progress_indicator.reset() # Cancel the installation thread if it exists if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning(): self.install_thread.cancel() self.install_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown if self.install_thread.isRunning(): self.install_thread.terminate() # Force terminate if needed self.install_thread.wait(1000) # Cancel the automated prefix thread if it exists if hasattr(self, 'prefix_thread') and self.prefix_thread and self.prefix_thread.isRunning(): self.prefix_thread.terminate() self.prefix_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown if self.prefix_thread.isRunning(): self.prefix_thread.terminate() # Force terminate if needed self.prefix_thread.wait(1000) # Cancel the configuration thread if it exists if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning(): self.config_thread.terminate() self.config_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown if self.config_thread.isRunning(): self.config_thread.terminate() # Force terminate if needed self.config_thread.wait(1000) # Cleanup any remaining processes self.cleanup_processes() # Reset button states and re-enable all controls self._enable_controls_after_operation() self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) except Exception as e: debug_print(f"ERROR: Exception during cancellation cleanup: {e}") import traceback traceback.print_exc() finally: # Always write cancellation message to console so detection works self._safe_append_text("Installation cancelled by user.") def _show_somnium_post_install_guidance(self): """Show guidance popup for Somnium post-installation steps""" from ..services.message_service import MessageService guidance_text = f"""Somnium Post-Installation Required

Due to Somnium's non-standard folder structure, you need to manually update the binary paths in ModOrganizer:

1. Launch the Steam shortcut created for Somnium
2. In ModOrganizer, go to Settings → Executables
3. For each executable entry (SKSE64, etc.), update the binary path to point to:
{self._somnium_install_dir}/files/root/Enderal Special Edition/skse64_loader.exe

Note: Full Somnium support will be added in a future Jackify update.

You can also refer to the Somnium installation guide at:
https://wiki.scenicroute.games/Somnium/1_Installation.html
""" MessageService.information(self, "Somnium Setup Required", guidance_text) # Reset the guidance flag self._show_somnium_guidance = False self._somnium_install_dir = None def cancel_and_cleanup(self): """Handle Cancel button - clean up processes and go back""" self.cleanup_processes() self.go_back() def reset_screen_to_defaults(self): """Reset the screen to default state when navigating back from main menu""" # Reset form fields self.modlist_btn.setText("Select Modlist") self.modlist_btn.setEnabled(False) self.file_edit.setText("") self.modlist_name_edit.setText("") self.install_dir_edit.setText(self.config_handler.get_modlist_install_base_dir()) # Reset game type button self.game_type_btn.setText("Please Select...") # Clear console and process monitor self.console.clear() self.process_monitor.clear() # Reset tabs to first tab (Online) self.source_tabs.setCurrentIndex(0) # Reset resolution combo to saved config preference saved_resolution = self.resolution_service.get_saved_resolution() if saved_resolution: combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())] resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items) self.resolution_combo.setCurrentIndex(resolution_index) elif self.resolution_combo.count() > 0: self.resolution_combo.setCurrentIndex(0) # Fallback to "Leave unchanged" # Re-enable controls (in case they were disabled from previous errors) self._enable_controls_after_operation()