""" 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, QCheckBox from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject, QUrl from PySide6.QtGui import QPixmap, QTextCursor from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS from ..utils import ansi_to_html import os import subprocess import sys import threading import time from jackify.backend.handlers.shortcut_handler import ShortcutHandler import traceback import signal from jackify.backend.core.modlist_operations import get_jackify_engine_path import re from jackify.backend.handlers.subprocess_utils import ProcessManager from jackify.backend.services.api_key_service import APIKeyService from jackify.backend.services.resolution_service import ResolutionService 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 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 # Only log on success, not on every call with open(self.log_path, 'a') as logf: logf.write(f"[Backend Success] Found {len(modlist_infos)} modlists for {self.game_type}\n") self.result.emit(modlist_infos, '') except Exception as e: error_msg = f"Backend service error: {str(e)}" with open(self.log_path, 'a') as logf: logf.write(f"[Backend Error] {error_msg}\n") self.result.emit([], error_msg) class SelectionDialog(QDialog): def __init__(self, title, items, parent=None): super().__init__(parent) self.setWindowTitle(title) self.setModal(True) self.setMinimumWidth(350) self.setMinimumHeight(300) layout = QVBoxLayout(self) self.list_widget = QListWidget() self.list_widget.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) for item in items: QListWidgetItem(item, self.list_widget) layout.addWidget(self.list_widget) self.selected_item = None self.list_widget.itemClicked.connect(self.on_item_clicked) def on_item_clicked(self, item): self.selected_item = item.text() self.accept() class TuxbornInstallerScreen(QWidget): steam_restart_finished = Signal(bool, str) def __init__(self, stacked_widget=None, main_menu_index=0): super().__init__() debug_print("DEBUG: TuxbornInstallerScreen __init__ called") self.stacked_widget = stacked_widget self.main_menu_index = main_menu_index self.debug = DEBUG_BORDERS self.online_modlists = {} # {game_type: [modlist_dict, ...]} self.modlist_details = {} # {modlist_name: modlist_dict} # Path for workflow log self.refresh_paths() # Initialize services early from jackify.backend.services.api_key_service import APIKeyService from jackify.backend.services.resolution_service import ResolutionService from jackify.backend.handlers.config_handler import ConfigHandler self.api_key_service = APIKeyService() self.resolution_service = ResolutionService() self.config_handler = ConfigHandler() # Scroll tracking for professional auto-scroll behavior self._user_manually_scrolled = False self._was_at_bottom = True # Time tracking for workflow completion self._workflow_start_time = None # Manual steps retry counter (legacy - should not be used in automated workflow) self._manual_steps_retry_count = 0 main_overall_vbox = QVBoxLayout(self) main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter) main_overall_vbox.setContentsMargins(50, 25, 50, 0) # Reduce top margin to move header closer to top if self.debug: self.setStyleSheet("border: 2px solid magenta;") # --- Header (title, description) --- header_layout = QVBoxLayout() header_layout.setSpacing(2) # Title (no logo) title = QLabel("Tuxborn Automatic Installer") title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};") title.setAlignment(Qt.AlignHCenter) header_layout.addWidget(title) # Description desc = QLabel( "This screen allows you to install the Tuxborn modlist using Jackify's native Linux tools. " "Configure your options and start the installation." ) desc.setWordWrap(True) desc.setStyleSheet("color: #ccc;") desc.setAlignment(Qt.AlignHCenter) header_layout.addWidget(desc) header_widget = QWidget() header_widget.setLayout(header_layout) header_widget.setMaximumHeight(75) # Prevent expansion, match Install a Modlist screen 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) # Left: user-configurables (form and controls) user_config_vbox = QVBoxLayout() user_config_vbox.setAlignment(Qt.AlignTop) # --- Tabs for source selection --- # self.source_tabs = QTabWidget() # REMOVE # --- Online List Tab --- # online_tab = QWidget() # online_tab_vbox = QVBoxLayout() # online_tab_vbox.setAlignment(Qt.AlignTop) # Game selection removed - Tuxborn is pre-selected # 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) # file_btn = QPushButton("Browse") # file_btn.clicked.connect(self.browse_wabbajack_file) # file_layout.addWidget(QLabel(".wabbajack File:")) # file_layout.addWidget(self.file_edit) # file_layout.addWidget(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) # Match Install a Modlist screen spacing form_grid.setContentsMargins(0, 0, 0, 0) # Modlist Name (NEW FIELD) modlist_name_label = QLabel("Modlist Name:") self.modlist_name_edit = QLineEdit("Tuxborn") 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 browse_install_btn = QPushButton("Browse") 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(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 browse_downloads_btn = QPushButton("Browse") 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(browse_downloads_btn) form_grid.addWidget(downloads_dir_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) form_grid.addLayout(downloads_dir_hbox, 2, 1) # API Key api_key_label = QLabel("Nexus API Key:") self.api_key_edit = QLineEdit() self.api_key_edit.setMaximumHeight(25) # Force compact height # Services already initialized above # Set up obfuscation timer and state self.api_key_obfuscation_timer = QTimer(self) self.api_key_obfuscation_timer.setSingleShot(True) self.api_key_obfuscation_timer.timeout.connect(self._obfuscate_api_key) self.api_key_original_text = "" self.api_key_is_obfuscated = False # Connect events for obfuscation self.api_key_edit.textChanged.connect(self._on_api_key_text_changed) self.api_key_edit.focusInEvent = self._on_api_key_focus_in self.api_key_edit.focusOutEvent = self._on_api_key_focus_out # Load saved API key if available saved_key = self.api_key_service.get_saved_api_key() if saved_key: self.api_key_original_text = saved_key # Set original text first self.api_key_edit.setText(saved_key) self._obfuscate_api_key() # Immediately obfuscate saved keys form_grid.addWidget(api_key_label, 3, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) form_grid.addWidget(self.api_key_edit, 3, 1) # API Key save checkbox and info (row 4) api_save_layout = QHBoxLayout() api_save_layout.setContentsMargins(0, 0, 0, 0) api_save_layout.setSpacing(8) self.save_api_key_checkbox = QCheckBox("Save API Key") self.save_api_key_checkbox.setChecked(self.api_key_service.has_saved_api_key()) self.save_api_key_checkbox.toggled.connect(self._on_api_key_save_toggled) api_save_layout.addWidget(self.save_api_key_checkbox, alignment=Qt.AlignTop) # Validate button removed - validation now happens silently on save checkbox toggle api_info = QLabel( 'Storing your API Key locally is done so at your own risk.
' 'You can get your API key at: ' 'https://www.nexusmods.com/users/myaccount?tab=api
' ) api_info.setOpenExternalLinks(False) api_info.linkActivated.connect(self._open_url_safe) api_info.setWordWrap(True) api_info.setAlignment(Qt.AlignLeft) api_save_layout.addWidget(api_info, stretch=1) api_save_widget = QWidget() api_save_widget.setLayout(api_save_layout) # Set reasonable maximum height to prevent excessive size while allowing natural height api_save_widget.setMaximumHeight(55) # Increase by another 2px for better fit if self.debug: api_save_widget.setStyleSheet("border: 2px solid lightblue;") api_save_widget.setToolTip("API_SAVE_SECTION") form_grid.addWidget(api_save_widget, 4, 1) # --- 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) form_grid.addWidget(self.resolution_combo, 5, 1) form_section_widget = QWidget() form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) form_section_widget.setLayout(form_grid) form_section_widget.setMinimumHeight(220) # Match Install a Modlist screen form_section_widget.setMaximumHeight(240) # Match Install a Modlist screen if self.debug: form_section_widget.setStyleSheet("border: 2px solid blue;") form_section_widget.setToolTip("FORM_SECTION") user_config_vbox.addWidget(form_section_widget) user_config_widget = QWidget() user_config_widget.setLayout(user_config_vbox) if self.debug: user_config_widget.setStyleSheet("border: 2px solid orange;") user_config_widget.setToolTip("USER_CONFIG_WIDGET") # Right: process monitor (as before) 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) if self.debug: process_monitor_widget.setStyleSheet("border: 2px solid purple;") process_monitor_widget.setToolTip("PROCESS_MONITOR") upper_hbox.addWidget(user_config_widget, stretch=11) upper_hbox.addWidget(process_monitor_widget, stretch=9) upper_hbox.setAlignment(Qt.AlignTop) upper_section_widget = QWidget() upper_section_widget.setLayout(upper_hbox) upper_section_widget.setMaximumWidth(1300) upper_section_widget.setMaximumHeight(235) # Increase by another 15px for better fit if self.debug: upper_section_widget.setStyleSheet("border: 2px solid green;") upper_section_widget.setToolTip("UPPER_SECTION") main_overall_vbox.addWidget(upper_section_widget) # Remove spacing - console should expand to fill available space # --- Buttons (moved BEFORE console creation) --- btn_row = QHBoxLayout() btn_row.setAlignment(Qt.AlignHCenter) self.start_btn = QPushButton("Start Installation") btn_row.addWidget(self.start_btn) self.cancel_btn = QPushButton("Cancel") self.cancel_btn.clicked.connect(self.go_back) btn_row.addWidget(self.cancel_btn) self.cancel_install_btn = QPushButton("Cancel Installation") self.cancel_install_btn.clicked.connect(self.cancel_installation) self.cancel_install_btn.setVisible(False) # Hidden until installation starts btn_row.addWidget(self.cancel_install_btn) btn_row_widget = QWidget() btn_row_widget.setLayout(btn_row) btn_row_widget.setMaximumHeight(50) if self.debug: btn_row_widget.setStyleSheet("border: 2px solid red;") btn_row_widget.setToolTip("BUTTON_ROW") # --- Console output area (full width, placeholder for now) --- self.console = QTextEdit() self.console.setReadOnly(True) self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) self.console.setMinimumHeight(50) # Very small minimum - can shrink to almost nothing self.console.setMaximumHeight(1000) # Allow growth when space available 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(8) # Small gap between console and buttons console_and_buttons_layout.addWidget(self.console, stretch=1) # Console fills most space console_and_buttons_layout.addWidget(btn_row_widget) # Buttons at bottom of this container console_and_buttons_widget.setLayout(console_and_buttons_layout) if self.debug: console_and_buttons_widget.setStyleSheet("border: 2px solid lightblue;") console_and_buttons_widget.setToolTip("CONSOLE_AND_BUTTONS_CONTAINER") main_overall_vbox.addWidget(console_and_buttons_widget, stretch=1) # This container fills remaining space 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) 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() / 'Tuxborn_Installer_workflow.log' os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True) def _open_url_safe(self, url): """Safely open URL using subprocess to avoid Qt library conflicts in PyInstaller""" 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 - always reload saved API key and parent directories""" super().showEvent(event) # Always reload saved API key to pick up changes from Settings dialog saved_key = self.api_key_service.get_saved_api_key() if saved_key: self.api_key_original_text = saved_key self.api_key_edit.setText(saved_key) self.api_key_is_obfuscated = False # Start unobfuscated # Set checkbox state self.save_api_key_checkbox.setChecked(True) # Immediately obfuscate saved keys (don't wait 3 seconds) self._obfuscate_api_key() elif not self.api_key_edit.text().strip(): # Only clear if no saved key and field is empty self.api_key_original_text = "" self.save_api_key_checkbox.setChecked(False) # Load saved parent directories and pre-populate fields self._load_saved_parent_directories() def _load_saved_parent_directories(self): """Load standard Settings menu defaults and pre-populate directory fields""" try: # Use the same Settings menu defaults as other workflows install_base_dir = self.config_handler.get("modlist_install_base_dir", os.path.expanduser("~/Games")) if install_base_dir: # Pre-populate with standard base + Skyrim + Tuxborn suggested_install_dir = os.path.join(install_base_dir, "Skyrim", "Tuxborn") self.install_dir_edit.setText(suggested_install_dir) debug_print(f"DEBUG: Pre-populated install directory with Settings default: {suggested_install_dir}") # Load standard download base directory downloads_base_dir = self.config_handler.get("modlist_downloads_base_dir", os.path.expanduser("~/Games/Modlist_Downloads")) if downloads_base_dir: # Pre-populate with standard downloads base self.downloads_dir_edit.setText(downloads_base_dir) debug_print(f"DEBUG: Pre-populated download directory with Settings default: {downloads_base_dir}") except Exception as e: print(f"DEBUG: Error loading Settings menu defaults: {e}") def _save_parent_directories(self, install_dir, downloads_dir): """Removed automatic saving - user should set defaults in settings""" pass def _on_api_key_text_changed(self, text): """Handle API key text changes for obfuscation timing""" if not self.api_key_is_obfuscated: self.api_key_original_text = text # Restart the obfuscation timer (3 seconds after last change) self.api_key_obfuscation_timer.stop() if text.strip(): # Only start timer if there's actual text self.api_key_obfuscation_timer.start(3000) # 3 seconds else: # If currently obfuscated and user is typing/pasting, un-obfuscate if text != self.api_key_service.get_api_key_display(self.api_key_original_text): self.api_key_is_obfuscated = False self.api_key_original_text = text if text.strip(): self.api_key_obfuscation_timer.start(3000) def _on_api_key_focus_out(self, event): """Handle API key field losing focus - immediately obfuscate""" QLineEdit.focusOutEvent(self.api_key_edit, event) self._obfuscate_api_key() def _on_api_key_focus_in(self, event): """Handle API key field gaining focus - de-obfuscate if needed""" # Call the original focusInEvent first QLineEdit.focusInEvent(self.api_key_edit, event) if self.api_key_is_obfuscated: self.api_key_edit.blockSignals(True) self.api_key_edit.setText(self.api_key_original_text) self.api_key_is_obfuscated = False self.api_key_edit.blockSignals(False) self.api_key_obfuscation_timer.stop() def _obfuscate_api_key(self): """Obfuscate the API key text field""" if not self.api_key_is_obfuscated and self.api_key_original_text.strip(): self.api_key_edit.blockSignals(True) masked_text = self.api_key_service.get_api_key_display(self.api_key_original_text) self.api_key_edit.setText(masked_text) self.api_key_is_obfuscated = True self.api_key_edit.blockSignals(False) def _get_actual_api_key(self): """Get the actual API key value (not the obfuscated version)""" if self.api_key_is_obfuscated: return self.api_key_original_text else: return self.api_key_edit.text() def open_game_type_dialog(self): dlg = SelectionDialog("Select Game Type", self.game_types, self) if dlg.exec() == QDialog.Accepted and dlg.selected_item: self.game_type_btn.setText(dlg.selected_item) self.fetch_modlists_for_game_type(dlg.selected_item) def fetch_modlists_for_game_type(self, game_type): 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", "Other": "other" } cli_game_type = game_type_map.get(game_type, "other") log_path = self.modlist_log_path # Use backend service for listing modlists 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 both new format (modlist objects) and old format (string IDs) for 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 else: # New format - full modlist objects with enhanced metadata filtered_modlists = [m for m in modlist_infos if m and hasattr(m, 'id')] self.current_modlists = [m.id for m in filtered_modlists] # Keep IDs for selection if error: self.modlist_btn.setText("Error fetching modlists.") self.modlist_btn.setEnabled(False) self._safe_append_text(f"[Modlist Fetch Error]\n{error}") elif self.current_modlists: 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): if not self.current_modlists: return dlg = SelectionDialog("Select Modlist", self.current_modlists, self) if dlg.exec() == QDialog.Accepted and dlg.selected_item: self.modlist_btn.setText(dlg.selected_item) # Store selection as needed 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): if self.stacked_widget: self.stacked_widget.setCurrentIndex(0) # Return to Main Menu 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) 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 _on_api_key_save_toggled(self, checked): """Handle immediate API key saving with silent validation when checkbox is toggled""" try: if checked: # Save API key if one is entered api_key = self._get_actual_api_key().strip() if api_key: # Silently validate API key first is_valid, validation_message = self.api_key_service.validate_api_key_works(api_key) if not is_valid: # Show error dialog for invalid API key from jackify.frontends.gui.services.message_service import MessageService MessageService.critical( self, "Invalid API Key", f"The API key is invalid and cannot be saved.\n\nError: {validation_message}", safety_level="low" ) self.save_api_key_checkbox.setChecked(False) # Uncheck on validation failure return # API key is valid, proceed with saving success = self.api_key_service.save_api_key(api_key) if success: self._show_api_key_feedback("✓ API key saved successfully", is_success=True) print("DEBUG: API key validated and saved immediately on checkbox toggle") else: self._show_api_key_feedback("✗ Failed to save API key - check permissions", is_success=False) # Uncheck the checkbox since save failed self.save_api_key_checkbox.setChecked(False) print("DEBUG: Failed to save API key immediately") else: self._show_api_key_feedback("Enter an API key first", is_success=False) # Uncheck the checkbox since no key to save self.save_api_key_checkbox.setChecked(False) else: # Clear saved API key when unchecked if self.api_key_service.has_saved_api_key(): success = self.api_key_service.clear_saved_api_key() if success: self._show_api_key_feedback("✓ API key cleared", is_success=True) print("DEBUG: Saved API key cleared immediately on checkbox toggle") else: self._show_api_key_feedback("✗ Failed to clear API key", is_success=False) print("DEBUG: Failed to clear API key") except Exception as e: self._show_api_key_feedback(f"✗ Error: {str(e)}", is_success=False) self.save_api_key_checkbox.setChecked(False) print(f"DEBUG: Error in _on_api_key_save_toggled: {e}") def _show_api_key_feedback(self, message, is_success=True): """Show temporary feedback message for API key operations""" # Use tooltip for immediate feedback color = "#22c55e" if is_success else "#ef4444" # Green for success, red for error self.save_api_key_checkbox.setToolTip(message) # Temporarily change checkbox style to show feedback original_style = self.save_api_key_checkbox.styleSheet() feedback_style = f"QCheckBox {{ color: {color}; font-weight: bold; }}" self.save_api_key_checkbox.setStyleSheet(feedback_style) # Reset style and tooltip after 3 seconds from PySide6.QtCore import QTimer def reset_feedback(): self.save_api_key_checkbox.setStyleSheet(original_style) self.save_api_key_checkbox.setToolTip("") QTimer.singleShot(3000, reset_feedback) def validate_and_start_install(self): try: debug_print('DEBUG: validate_and_start_install called') # 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) # Start time tracking self._workflow_start_time = time.time() # Hardcode Tuxborn values modlist = 'Tuxborn/Tuxborn' install_mode = 'online' install_dir = self.install_dir_edit.text().strip() downloads_dir = self.downloads_dir_edit.text().strip() # Get the actual API key (not obfuscated version) api_key = self._get_actual_api_key().strip() 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) if not dlg.exec() or not dlg.confirmed: 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?", safety_level="low") 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}", safety_level="medium") return else: 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?", safety_level="low") 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}", safety_level="medium") return else: return # Handle API key saving BEFORE validation (to match settings dialog behavior) if self.save_api_key_checkbox.isChecked(): if api_key: success = self.api_key_service.save_api_key(api_key) if success: debug_print("DEBUG: API key saved successfully") else: debug_print("DEBUG: Failed to save API key") else: # If checkbox is unchecked, clear any saved API key if self.api_key_service.has_saved_api_key(): self.api_key_service.clear_saved_api_key() debug_print("DEBUG: Saved API key cleared") # Validate API key for installation purposes if not api_key or not self.api_key_service._validate_api_key_format(api_key): MessageService.warning(self, "Invalid API Key", "Please enter a valid Nexus API Key.", safety_level="low") 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) self.console.clear() self.process_monitor.clear() self.start_btn.setEnabled(False) debug_print(f'DEBUG: Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, api_key={api_key[:6]}..., 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}") 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') # Clear console for fresh installation output self.console.clear() self._safe_append_text("Starting Tuxborn 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) installation_finished = Signal(bool, str) def __init__(self, modlist, install_dir, downloads_dir, api_key, modlist_name): 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.cancelled = False self.process_manager = None def cancel(self): self.cancelled = True if self.process_manager: self.process_manager.cancel() def run(self): import re try: engine_path = get_jackify_engine_path() cmd = [engine_path, "install", "-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") env = os.environ.copy() env['NEXUS_API_KEY'] = self.api_key self.process_manager = ProcessManager(cmd, env=env, text=False, bufsize=0) ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]') buffer = b'' 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) self.progress_received.emit(line.decode('utf-8', errors='replace')) elif b'\n' in buffer: line, buffer = buffer.split(b'\n', 1) line = ansi_escape.sub(b'', line) self.output_received.emit(line.decode('utf-8', errors='replace')) if buffer: line = ansi_escape.sub(b'', buffer) self.output_received.emit(line.decode('utf-8', errors='replace')) self.process_manager.wait() if self.cancelled: self.installation_finished.emit(False, "Installation cancelled by user") elif self.process_manager.proc.returncode == 0: self.installation_finished.emit(True, "Installation completed successfully") else: self.installation_finished.emit(False, "Installation failed") except Exception as e: import traceback error_msg = f"Installation error: {e}\n{traceback.format_exc()}" self.installation_finished.emit(False, error_msg) finally: if self.cancelled and self.process_manager: self.process_manager.cancel() # Create and start thread modlist_name = self.modlist_name_edit.text().strip() self.install_thread = InstallationThread(modlist, install_dir, downloads_dir, api_key, modlist_name) # Connect signals for proper GUI updates self.install_thread.output_received.connect(self.on_installation_output) self.install_thread.progress_received.connect(self.on_installation_progress) self.install_thread.installation_finished.connect(self.on_installation_finished) # Start installation self.install_thread.start() self._safe_append_text("Installation thread started...") def on_installation_output(self, message): """Handle regular output from installation thread""" self._safe_append_text(message) def on_installation_progress(self, progress_message): """Replace the last line in the console for progress updates""" cursor = self.console.textCursor() cursor.movePosition(QTextCursor.End) cursor.movePosition(QTextCursor.StartOfLine, QTextCursor.KeepAnchor) cursor.removeSelectedText() cursor.insertText(progress_message) # Don't force scroll for progress updates - let user control def on_installation_finished(self, success, message): """Handle installation completion""" if success: self._safe_append_text(f"\nSuccess: {message}") self.process_finished(0, QProcess.NormalExit, message) # Simulate successful completion else: self._safe_append_text(f"\nError: {message}") self.process_finished(1, QProcess.CrashExit, message) # Simulate error def process_finished(self, exit_code, exit_status, message=None): # Reset button states self.start_btn.setEnabled(True) self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) # Import MessageService at the top of the method for all code paths from jackify.frontends.gui.services.message_service import MessageService if exit_code == 0: # Only show the install complete dialog here 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!", safety_level="medium" ) if reply == QMessageBox.Yes: # Proceed directly to restart Steam - automated workflow will handle shortcut creation self.restart_steam_and_configure() 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" ) else: # Check for user cancellation if message and "cancelled by user" in message.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.", safety_level="medium") 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 _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""" # Write all messages to log file self._write_to_log_file(text) 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(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 _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""" 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() self.setEnabled(False) def do_restart(): 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 print("DEBUG: About to call secure_steam_restart()") success = shortcut_handler.secure_steam_restart() print(f"DEBUG: secure_steam_restart() returned: {success}") out = "Steam restart completed successfully." if success else "Steam restart failed." except Exception as e: 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): 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: print(f"DEBUG: Error closing progress dialog: {e}") finally: self._steam_restart_progress = None self.setEnabled(True) if success: self._safe_append_text("Steam restarted successfully.") # Use automated prefix service instead of manual steps self._safe_append_text("Starting automated Steam setup workflow...") # Start automated prefix workflow modlist_name = self.modlist_name_edit.text().strip() install_dir = self.install_dir_edit.text().strip() mo2_exe_path = os.path.join(install_dir, "ModOrganizer.exe") self._start_automated_prefix_workflow(modlist_name, install_dir, mo2_exe_path) 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.", safety_level="medium") def _start_automated_prefix_workflow(self, modlist_name, install_dir, mo2_exe_path): """Start the automated prefix workflow using AutomatedPrefixService in a background thread""" self._safe_append_text(f"Initializing automated Steam setup for '{modlist_name}'...") self._safe_append_text("Starting automated Steam shortcut creation and configuration...") # Create and start the automated prefix thread class AutomatedPrefixThread(QThread): progress_update = Signal(str) workflow_complete = Signal(object) # Will emit the result tuple error_occurred = Signal(str) def __init__(self, modlist_name, install_dir, mo2_exe_path): super().__init__() self.modlist_name = modlist_name self.install_dir = install_dir self.mo2_exe_path = mo2_exe_path def run(self): try: from jackify.backend.services.automated_prefix_service import AutomatedPrefixService # Initialize the automated prefix service prefix_service = AutomatedPrefixService() # Define progress callback for GUI updates def progress_callback(message): self.progress_update.emit(message) # Run the automated workflow (this contains the blocking operations) result = prefix_service.run_working_workflow( self.modlist_name, self.install_dir, self.mo2_exe_path, progress_callback ) # Emit the result self.workflow_complete.emit(result) except Exception as e: self.error_occurred.emit(str(e)) # Create and start the thread self.automated_prefix_thread = AutomatedPrefixThread(modlist_name, install_dir, mo2_exe_path) self.automated_prefix_thread.progress_update.connect(self._safe_append_text) self.automated_prefix_thread.workflow_complete.connect(self._on_automated_prefix_complete) self.automated_prefix_thread.error_occurred.connect(self._on_automated_prefix_error) self.automated_prefix_thread.start() def _on_automated_prefix_complete(self, result): """Handle completion of the automated prefix workflow""" try: modlist_name = self.modlist_name_edit.text().strip() install_dir = self.install_dir_edit.text().strip() # Handle the result - check for conflicts if isinstance(result, tuple) and len(result) == 4: if result[0] == "CONFLICT": # Conflict detected - show conflict resolution dialog conflicts = result[1] self.show_shortcut_conflict_dialog(conflicts) return else: # Normal result success, prefix_path, new_appid, last_timestamp = result if success: self._safe_append_text(f"Automated Steam setup completed successfully!") self._safe_append_text(f"New AppID assigned: {new_appid}") # Continue with post-Steam configuration, passing the last timestamp self.continue_configuration_after_automated_prefix(new_appid, modlist_name, install_dir, last_timestamp) else: self._safe_append_text(f"Automated Steam setup failed") self._safe_append_text("Please check the logs for details.") self.start_btn.setEnabled(True) self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) elif isinstance(result, tuple) and len(result) == 3: # Fallback for old format (backward compatibility) success, prefix_path, new_appid = result if success: self._safe_append_text(f"Automated Steam setup completed successfully!") self._safe_append_text(f"New AppID assigned: {new_appid}") # Continue with post-Steam configuration self.continue_configuration_after_automated_prefix(new_appid, modlist_name, install_dir) else: self._safe_append_text(f"Automated Steam setup failed") self._safe_append_text("Please check the logs for details.") self.start_btn.setEnabled(True) self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) else: # Handle unexpected result format self._safe_append_text(f"Automated Steam setup failed - unexpected result format") self._safe_append_text("Please check the logs for details.") self.start_btn.setEnabled(True) self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) except Exception as e: self._safe_append_text(f"Error handling automated prefix result: {str(e)}") self.start_btn.setEnabled(True) self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) def _on_automated_prefix_error(self, error_message): """Handle error from the automated prefix workflow""" self._safe_append_text(f"Error during automated Steam setup: {error_message}") self._safe_append_text("Please check the logs for details.") self.start_btn.setEnabled(True) self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) 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.backend.services.message_service import MessageService MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.") else: # Empty name from jackify.backend.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}'") modlist_name = self.modlist_name_edit.text().strip() install_dir = self.install_dir_edit.text().strip() mo2_exe_path = os.path.join(install_dir, "ModOrganizer.exe") self._start_automated_prefix_workflow(modlist_name, install_dir, mo2_exe_path) 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.") # Reset button states self.start_btn.setEnabled(True) self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) 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 = os.path.join(install_dir, "ModOrganizer.exe") # 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 shortcut_handler = ShortcutHandler(steamdeck=False) 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.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() modlist_handler = ModlistHandler(steamdeck=False, verbose=False) # Set required properties manually after initialization modlist_handler.modlist_dir = install_dir modlist_handler.appid = current_appid modlist_handler.game_var = "skyrimspecialedition" # Tuxborn is always Skyrim # 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}...") 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}") 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 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""" if last_timestamp: # Initialize timing to continue from the last timestamp from jackify.shared.timing import continue_from_timestamp continue_from_timestamp(last_timestamp) debug_print(f"Timing continued from {last_timestamp}") 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': os.path.join(install_dir, "ModOrganizer.exe"), 'modlist_value': 'Tuxborn/Tuxborn', # Hardcoded for Tuxborn 'modlist_source': 'identifier', 'resolution': getattr(self, '_current_resolution', '2560x1600'), '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}") # 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): super().__init__() self.context = context 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 system_info = SystemInfo(is_steamdeck=False) 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['modlist_value'], modlist_source=self.context['modlist_source'], resolution=self.context.get('resolution', '2560x1600'), 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 self.progress_update.emit("") self.progress_update.emit("=== Configuration Phase ===") self.progress_update.emit("") self.progress_update.emit("Starting modlist 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) self.config_thread.progress_update.connect(self._safe_append_text) 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': os.path.join(install_dir, "ModOrganizer.exe"), 'modlist_value': 'Tuxborn/Tuxborn', # Hardcoded for Tuxborn 'modlist_source': 'identifier', 'resolution': getattr(self, '_current_resolution', '2560x1600'), 'skip_confirmation': True, 'manual_steps_completed': True, # Mark as completed 'appid': new_appid, # Use the NEW AppID from Steam '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}") # 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): super().__init__() self.context = context 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 system_info = SystemInfo(is_steamdeck=False) 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', # Tuxborn is always Skyrim nexus_api_key='', # Not needed for configuration modlist_value=self.context.get('modlist_value', 'Tuxborn/Tuxborn'), modlist_source=self.context.get('modlist_source', 'identifier'), resolution=self.context.get('resolution'), # Pass resolution from GUI context 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 self.progress_update.emit("Starting Tuxborn configuration (post-Steam setup)...") result = modlist_service.configure_modlist_post_steam( context=modlist_context, progress_callback=progress_callback, manual_steps_callback=manual_steps_callback, completion_callback=completion_callback ) except Exception as e: self.error_occurred.emit(str(e)) # Clean up old thread if exists 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 = ConfigThread(updated_context) self.config_thread.progress_update.connect(self._safe_append_text) 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 on_configuration_complete(self, success, message, modlist_name): """Handle configuration completion on main thread""" # Always re-enable the start button when workflow completes self.start_btn.setEnabled(True) self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) if success: # Calculate time taken time_taken = self._calculate_time_taken() # Show success dialog with celebration game_name = self.context.get('game_name', 'Skyrim Special Edition') success_dialog = SuccessDialog( modlist_name="Tuxborn", workflow_type="tuxborn", time_taken=time_taken, game_name=game_name, parent=self ) success_dialog.show() elif 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.", safety_level="medium") else: # Configuration failed for other reasons MessageService.critical(self, "Configuration Failed", "Post-install configuration failed. Please check the console output.", safety_level="medium") # 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""" # Re-enable the start button on error self.start_btn.setEnabled(True) self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) self._safe_append_text(f"Configuration failed with error: {error_message}") MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}", safety_level="medium") # 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 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 MessageService.critical(self, "Manual Steps Incomplete", f"Manual steps validation failed:\n\n{missing_text}\n\n" "Please complete the missing steps and try again.", safety_level="medium") # 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.", safety_level="medium") 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 all threads") # Clean up automated prefix thread if running if hasattr(self, 'automated_prefix_thread') and self.automated_prefix_thread and self.automated_prefix_thread.isRunning(): debug_print("DEBUG: Terminating AutomatedPrefixThread") try: self.automated_prefix_thread.progress_update.disconnect() self.automated_prefix_thread.workflow_complete.disconnect() self.automated_prefix_thread.error_occurred.disconnect() except: pass self.automated_prefix_thread.terminate() self.automated_prefix_thread.wait(2000) # Wait up to 2 seconds # 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 = [ '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?", safety_level="low" ) if reply == QMessageBox.Yes: self._safe_append_text("\n🛑 Cancelling installation...") # Cancel the installation thread if it exists if hasattr(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) # Cleanup any remaining processes self.cleanup_processes() # Reset button states self.start_btn.setEnabled(True) self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) self._safe_append_text("Installation cancelled by user.") def closeEvent(self, event): """Handle window close event - clean up processes""" self.cleanup_processes() event.accept() def _calculate_time_taken(self) -> str: """Calculate and format the time taken for the workflow""" if self._workflow_start_time is None: return "unknown time" elapsed_seconds = time.time() - self._workflow_start_time elapsed_minutes = int(elapsed_seconds // 60) elapsed_seconds_remainder = int(elapsed_seconds % 60) if elapsed_minutes > 0: if elapsed_minutes == 1: return f"{elapsed_minutes} minute {elapsed_seconds_remainder} seconds" else: return f"{elapsed_minutes} minutes {elapsed_seconds_remainder} seconds" else: return f"{elapsed_seconds_remainder} seconds"