"""Configuration phase workflow for InstallModlistScreen (Mixin).""" from PySide6.QtWidgets import QMessageBox, QProgressDialog from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtGui import QFont from jackify.frontends.gui.services.message_service import MessageService from jackify.shared.errors import manual_steps_incomplete, configuration_failed from jackify.frontends.gui.dialogs import SuccessDialog from jackify.backend.handlers.validation_handler import ValidationHandler from jackify.backend.models.modlist import ModlistContext from pathlib import Path import traceback import os import time import logging logger = logging.getLogger(__name__) from .install_modlist_shortcut_dialog import InstallModlistShortcutDialogMixin class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin): """Mixin providing configuration phase workflow and dialog management for InstallModlistScreen.""" 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""" 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 # Delay focus reclaim so Steam's window finishes painting before we steal it back try: from PySide6.QtCore import QTimer win = self.window() QTimer.singleShot(10000, lambda: (win.raise_(), win.activateWindow())) except Exception: pass def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str: """Detect game type by checking ModOrganizer.ini for loader executables.""" from pathlib import Path mo2_ini = Path(install_dir) / "ModOrganizer.ini" if not mo2_ini.exists(): return 'skyrim' # Fallback to most common try: content = mo2_ini.read_text(encoding='utf-8', errors='ignore').lower() if 'skse64_loader.exe' in content or 'skyrim special edition' in content: return 'skyrim' elif 'f4se_loader.exe' in content or 'fallout 4' in content: return 'fallout4' elif 'nvse_loader.exe' in content or 'fallout new vegas' in content: return 'falloutnv' elif 'obse_loader.exe' in content or 'oblivion' in content: return 'oblivion' elif 'starfield' in content: return 'starfield' elif 'enderal' in content: return 'enderal' else: return 'skyrim' except Exception as e: logger.warning(f"Error detecting game type from ModOrganizer.ini: {e}") return 'skyrim' def on_configuration_complete(self, success, message, modlist_name, enb_detected=False): """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() # Don't end post-install feedback yet - may continue with VNV automation # Will be called in _on_vnv_complete or after VNV check 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 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() ttw_modlist_name = modlist_name try: from jackify.backend.utils.modlist_meta import get_modlist_name canonical_name = get_modlist_name(install_dir) if canonical_name: ttw_modlist_name = canonical_name except Exception: pass if self._check_ttw_eligibility(ttw_modlist_name, self._current_game_type, install_dir): # Offer TTW installation reply = MessageService.question( self, "Install TTW?", f"{ttw_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: self._cleanup_config_thread() # Navigate to TTW screen self._initiate_ttw_workflow(ttw_modlist_name, install_dir) return # Don't show success dialog yet, will show after TTW completes # Check for VNV post-install automation after TTW check vnv_automation_running = self._check_and_run_vnv_automation(modlist_name, install_dir) if vnv_automation_running: self._cleanup_config_thread() # Store success dialog params for later (after VNV automation completes) self._pending_success_dialog_params = { 'modlist_name': modlist_name, 'time_taken': time_str, 'game_name': game_name, 'enb_detected': enb_detected } # Keep post-install feedback active during VNV automation # Don't show success dialog yet - will be shown in _on_vnv_complete return # No VNV automation - end post-install feedback now self._end_post_install_feedback(True) # 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() # Show ENB Proton dialog if ENB was detected (use stored detection result, no re-detection) if enb_detected: try: from ..dialogs.enb_proton_dialog import ENBProtonDialog enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self) enb_dialog.exec() # Modal dialog - blocks until user clicks OK except Exception as e: # Non-blocking: if dialog fails, just log and continue logger.warning(f"Failed to show ENB dialog: {e}") elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3: # Max retries reached - show failure message self._end_post_install_feedback(False) MessageService.show_error(self, manual_steps_incomplete()) else: # Configuration failed for other reasons self._end_post_install_feedback(False) MessageService.show_error(self, configuration_failed("Post-install configuration failed.")) except Exception as e: # Ensure controls are re-enabled even on unexpected errors self._enable_controls_after_operation() raise self._cleanup_config_thread() 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.show_error(self, configuration_failed(str(error_message))) # Re-enable all controls on error self._enable_controls_after_operation() self._cleanup_config_thread() def _cleanup_config_thread(self): """Safely stop and release the configuration worker thread.""" if not hasattr(self, 'config_thread') or self.config_thread is None: return try: self.config_thread.progress_update.disconnect() self.config_thread.configuration_complete.disconnect() self.config_thread.error_occurred.disconnect() except (RuntimeError, TypeError): pass if self.config_thread.isRunning(): self.config_thread.quit() self.config_thread.wait(5000) 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("\nManual 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 logger.info("Waiting for Steam filesystem updates to complete...") 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 logger.info(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}") logger.info(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 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: modlist_handler.compat_data_path = Path(compat_data_path_str) # Check Proton version logger.info(f"Attempting to detect Proton version for AppID {current_appid}...") if modlist_handler._detect_proton_version(): logger.info(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 logger.info(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() logger.info(f"Searching for compatdata directory for AppID {current_appid}...") logger.info("Checking standard Steam locations and Flatpak Steam...") prefix_path_str = path_handler.find_compat_data(current_appid) logger.info(f"Compatdata search result: '{prefix_path_str}'") if prefix_path_str and os.path.isdir(prefix_path_str): compatdata_ok = True logger.info(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!") logger.info("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""" # Headers are now shown at start of Steam Integration # No need to show them again here logger.debug("Configuration phase continues after Steam Integration") logger.debug(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 logger.debug(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 # Capture parent's method for game type detection detect_game_type_func = self._detect_game_type_from_mo2_ini class ConfigThread(QThread): progress_update = Signal(str) configuration_complete = Signal(bool, str, str, bool) error_occurred = Signal(str) def __init__(self, context, is_steamdeck, detect_func): super().__init__() self.context = context self.is_steamdeck = is_steamdeck self.detect_game_type = detect_func 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 # Initialize backend service with passed Steam Deck detection system_info = SystemInfo(is_steamdeck=self.is_steamdeck) modlist_service = ModlistService(system_info) # Detect game type from ModOrganizer.ini using captured function detected_game_type = self.detect_game_type(self.context['path']) # 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=detected_game_type, 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, enb_detected=False): self.configuration_complete.emit(success, message, modlist_name, enb_detected) def manual_steps_callback(modlist_name, retry_count): # Should not reach here -- prefix creation already 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, detect_game_type_func) 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._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 } logger.debug(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 (RuntimeError, TypeError): 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""" # 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 # Capture parent's method for game type detection detect_game_type_func = self._detect_game_type_from_mo2_ini class ConfigThread(QThread): progress_update = Signal(str) configuration_complete = Signal(bool, str, str) error_occurred = Signal(str) def __init__(self, context, is_steamdeck, detect_func, parent=None): super().__init__(parent) self.context = context self.is_steamdeck = is_steamdeck self.detect_game_type = detect_func 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 # Initialize backend service with passed Steam Deck detection system_info = SystemInfo(is_steamdeck=self.is_steamdeck) modlist_service = ModlistService(system_info) # Detect game type from ModOrganizer.ini using captured function detected_game_type = self.detect_game_type(self.context['path']) # 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=detected_game_type, 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): # Should not reach here -- manual steps already complete 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: 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, detect_game_type_func, parent=self)