""" InstallModlistScreen for Jackify GUI """ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QMessageBox, QProgressDialog, QApplication, QCheckBox, QStyledItemDelegate, QStyle, QFrame from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject, QUrl from PySide6.QtGui import QPixmap, QTextCursor, QPainter, QFont from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS from ..utils import ansi_to_html, strip_ansi_control_codes, set_responsive_minimum from ..widgets.unsupported_game_dialog import UnsupportedGameDialog from jackify.frontends.gui.widgets.file_progress_list import FileProgressList import os import subprocess import sys import threading from jackify.backend.handlers.shortcut_handler import ShortcutHandler from jackify.backend.handlers.wabbajack_parser import WabbajackParser import traceback from jackify.backend.core.modlist_operations import get_jackify_engine_path import signal import re import time from jackify.backend.handlers.subprocess_utils import ProcessManager from jackify.backend.handlers.config_handler import ConfigHandler from ..dialogs import SuccessDialog from jackify.backend.handlers.validation_handler import ValidationHandler from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog from jackify.frontends.gui.services.message_service import MessageService from .install_ttw_ui_setup import TTWUISetupMixin from .install_ttw_integration import TTWIntegrationMixin from .install_ttw_requirements import TTWRequirementsMixin from .install_ttw_lifecycle import TTWLifecycleMixin from .install_ttw_installer import TTWInstallerMixin from .install_ttw_workflow import TTWWorkflowMixin from .install_ttw_ui import TTWUIMixin from .install_ttw_config import TTWConfigMixin from .screen_back_mixin import ScreenBackMixin def debug_print(message): """Print debug message only if debug mode is enabled""" from jackify.backend.handlers.config_handler import ConfigHandler config_handler = ConfigHandler() if config_handler.get('debug_mode', False): print(message) class ModlistFetchThread(QThread): result = Signal(list, str) def __init__(self, game_type, log_path, mode='list-modlists'): super().__init__() self.game_type = game_type self.log_path = log_path self.mode = mode def run(self): try: # Use proper backend service - NOT the misnamed CLI class from jackify.backend.services.modlist_service import ModlistService from jackify.backend.models.configuration import SystemInfo # Initialize backend service # Detect if we're on Steam Deck is_steamdeck = False try: if os.path.exists('/etc/os-release'): with open('/etc/os-release') as f: if 'steamdeck' in f.read().lower(): is_steamdeck = True except Exception: pass system_info = SystemInfo(is_steamdeck=is_steamdeck) modlist_service = ModlistService(system_info) # Get modlists using proper backend service modlist_infos = modlist_service.list_modlists(game_type=self.game_type) # Return full modlist objects instead of just IDs to preserve enhanced metadata self.result.emit(modlist_infos, '') except Exception as e: error_msg = f"Backend service error: {str(e)}" # Don't write to log file before workflow starts - just return error self.result.emit([], error_msg) class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TTWRequirementsMixin, TTWLifecycleMixin, QWidget, TTWInstallerMixin, TTWWorkflowMixin, TTWUIMixin, TTWConfigMixin): steam_restart_finished = Signal(bool, str) resize_request = Signal(str) integration_complete = Signal(bool, str) # Signal for modlist integration completion (success, ttw_version) def _collect_actionable_controls(self): """Collect all actionable controls that should be disabled during operations (except Cancel)""" self._actionable_controls = [ # Main action button self.start_btn, # File selection self.file_edit, self.file_btn, # Install directory self.install_dir_edit, self.browse_install_btn, ] def _disable_controls_during_operation(self): """Disable all actionable controls during install/configure operations (except Cancel)""" for control in self._actionable_controls: if control: control.setEnabled(False) def _enable_controls_after_operation(self): """Re-enable all actionable controls after install/configure operations complete""" for control in self._actionable_controls: if control: control.setEnabled(True) def 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() / 'TTW_Install_workflow.log' os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True) def _open_url_safe(self, url): """Safely open URL via subprocess to avoid Qt library clashes inside the AppImage runtime""" import subprocess try: subprocess.Popen(['xdg-open', url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except Exception as e: print(f"Warning: Could not open URL {url}: {e}") def _load_saved_parent_directories(self): """No-op: do not pre-populate install/download directories from saved values.""" pass def _update_directory_suggestions(self, modlist_name): """Update directory suggestions based on modlist name""" try: if not modlist_name: return # Update install directory suggestion with modlist name saved_install_parent = self.config_handler.get_default_install_parent_dir() if saved_install_parent: suggested_install_dir = os.path.join(saved_install_parent, modlist_name) self.install_dir_edit.setText(suggested_install_dir) debug_print(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}") # Update download directory suggestion saved_download_parent = self.config_handler.get_default_download_parent_dir() if saved_download_parent: suggested_download_dir = os.path.join(saved_download_parent, "Downloads") debug_print(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}") except Exception as e: debug_print(f"DEBUG: Error updating directory suggestions: {e}") def _save_parent_directories(self, install_dir, downloads_dir): """Removed automatic saving - user should set defaults in settings""" pass def browse_wabbajack_file(self): # Use QFileDialog instance to ensure consistent dialog style start_path = self.file_edit.text() if self.file_edit.text() else os.path.expanduser("~") dialog = QFileDialog(self, "Select TTW .mpi File") dialog.setFileMode(QFileDialog.ExistingFile) dialog.setNameFilter("MPI Files (*.mpi);;All Files (*)") dialog.setDirectory(start_path) dialog.setOption(QFileDialog.DontUseNativeDialog, True) # Force Qt dialog for consistency if dialog.exec() == QDialog.Accepted: files = dialog.selectedFiles() if files: self.file_edit.setText(files[0]) def browse_install_dir(self): # Use QFileDialog instance to match file browser style exactly dialog = QFileDialog(self, "Select Install Directory") dialog.setFileMode(QFileDialog.Directory) dialog.setOption(QFileDialog.ShowDirsOnly, True) dialog.setOption(QFileDialog.DontUseNativeDialog, True) # Force Qt dialog to match file browser if self.install_dir_edit.text(): dialog.setDirectory(self.install_dir_edit.text()) if dialog.exec() == QDialog.Accepted: dirs = dialog.selectedFiles() if dirs: self.install_dir_edit.setText(dirs[0]) def update_top_panel(self): try: result = subprocess.run([ "ps", "-eo", "pcpu,pmem,comm,args" ], stdout=subprocess.PIPE, text=True, timeout=2) lines = result.stdout.splitlines() header = "CPU%\tMEM%\tCOMMAND" filtered = [header] process_rows = [] for line in lines[1:]: line_lower = line.lower() if ( ("jackify-engine" in line_lower or "7zz" in line_lower or "texconv" in line_lower or "wine" in line_lower or "wine64" in line_lower or "protontricks" in line_lower or "ttw_linux" in line_lower) and "jackify-gui.py" not in line_lower ): cols = line.strip().split(None, 3) if len(cols) >= 3: process_rows.append(cols) process_rows.sort(key=lambda x: float(x[0]), reverse=True) for cols in process_rows: filtered.append('\t'.join(cols)) if len(filtered) == 1: filtered.append("[No Jackify-related processes found]") self.process_monitor.setPlainText('\n'.join(filtered)) except Exception as e: self.process_monitor.setPlainText(f"[process info unavailable: {e}]") def _check_protontricks(self): """Check if protontricks is available before critical operations""" try: if self.protontricks_service.is_bundled_mode(): return True is_installed, installation_type, details = self.protontricks_service.detect_protontricks() if not is_installed: # Show protontricks error dialog from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog dialog = ProtontricksErrorDialog(self.protontricks_service, self) result = dialog.exec() if result == QDialog.Rejected: return False # Re-check after dialog is_installed, _, _ = self.protontricks_service.detect_protontricks(use_cache=False) return is_installed return True except Exception as e: print(f"Error checking protontricks: {e}") MessageService.warning(self, "Protontricks Check Failed", f"Unable to verify protontricks installation: {e}\n\n" "Continuing anyway, but some features may not work correctly.") return True # Continue anyway def _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 handle_validation_failure(self, missing_text): """Handle failed validation with retry logic""" self._manual_steps_retry_count += 1 if self._manual_steps_retry_count < 3: # Show retry dialog with increasingly detailed guidance retry_guidance = "" if self._manual_steps_retry_count == 1: retry_guidance = "\n\nTip: Make sure Steam is fully restarted before trying again." elif self._manual_steps_retry_count == 2: retry_guidance = "\n\nTip: If using Flatpak Steam, ensure compatdata is being created in the correct location." MessageService.critical(self, "Manual Steps Incomplete", f"Manual steps validation failed:\n\n{missing_text}\n\n" f"Please complete the missing steps and try again.{retry_guidance}") # Show manual steps dialog again extra_warning = "" if self._manual_steps_retry_count >= 2: extra_warning = "
It looks like you have not completed the manual steps yet. Please try again." self.show_manual_steps_dialog(extra_warning) else: # Max retries reached MessageService.critical(self, "Manual Steps Failed", "Manual steps validation failed after multiple attempts.\n\n" "Common issues:\n" "• Steam not fully restarted\n" "• Shortcut not launched from Steam\n" "• Flatpak Steam using different file paths\n" "• Proton - Experimental not selected") self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self._current_modlist_name) def show_next_steps_dialog(self, message): from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout, QApplication dlg = QDialog(self) dlg.setWindowTitle("Next Steps") dlg.setModal(True) layout = QVBoxLayout(dlg) label = QLabel(message) label.setWordWrap(True) layout.addWidget(label) btn_row = QHBoxLayout() btn_return = QPushButton("Return") btn_exit = QPushButton("Exit") btn_row.addWidget(btn_return) btn_row.addWidget(btn_exit) layout.addLayout(btn_row) def on_return(): dlg.accept() if self.stacked_widget: self.stacked_widget.setCurrentIndex(0) # Main menu def on_exit(): QApplication.quit() btn_return.clicked.connect(on_return) btn_exit.clicked.connect(on_exit) dlg.exec() def cleanup_processes(self): """Clean up any running processes when the window closes or is cancelled""" debug_print("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes") # Clean up InstallationThread if running if hasattr(self, 'install_thread') and self.install_thread.isRunning(): debug_print("DEBUG: Cancelling running InstallationThread") self.install_thread.cancel() self.install_thread.wait(3000) # Wait up to 3 seconds if self.install_thread.isRunning(): self.install_thread.terminate() # Clean up other threads threads = [ 'prefix_thread', 'config_thread', 'fetch_thread' ] for thread_name in threads: if hasattr(self, thread_name): thread = getattr(self, thread_name) if thread and thread.isRunning(): debug_print(f"DEBUG: Terminating {thread_name}") thread.terminate() thread.wait(1000) # Wait up to 1 second def cancel_installation(self): """Cancel the currently running installation""" reply = MessageService.question( self, "Cancel Installation", "Are you sure you want to cancel the installation?", critical=False # Non-critical, won't steal focus ) if reply == QMessageBox.Yes: self._safe_append_text("\nCancelling installation...") # Stop the elapsed timer if running if hasattr(self, 'ttw_elapsed_timer') and self.ttw_elapsed_timer.isActive(): self.ttw_elapsed_timer.stop() # Update status banner if hasattr(self, 'status_banner'): self.status_banner.setText("Installation cancelled by user") self.status_banner.setStyleSheet(f""" background-color: #4d3d1a; color: #FFA500; padding: 8px; border-radius: 4px; font-weight: bold; font-size: 13px; """) # 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) # Cancel the automated prefix thread if it exists if hasattr(self, 'prefix_thread') and self.prefix_thread.isRunning(): self.prefix_thread.terminate() self.prefix_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown if self.prefix_thread.isRunning(): self.prefix_thread.terminate() # Force terminate if needed self.prefix_thread.wait(1000) # Cancel the configuration thread if it exists if hasattr(self, 'config_thread') and self.config_thread.isRunning(): self.config_thread.terminate() self.config_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown if self.config_thread.isRunning(): self.config_thread.terminate() # Force terminate if needed self.config_thread.wait(1000) # Cleanup any remaining processes self.cleanup_processes() # Reset button states and re-enable all controls self._enable_controls_after_operation() self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) # Collapse window if "Show Details" is checked if hasattr(self, 'show_details_checkbox') and self.show_details_checkbox.isChecked(): self.resize_request.emit('collapse') self.show_details_checkbox.blockSignals(True) self.show_details_checkbox.setChecked(False) self.show_details_checkbox.blockSignals(False) self._safe_append_text("Installation cancelled by user.") def _show_somnium_post_install_guidance(self): """Show guidance popup for Somnium post-installation steps""" from ..services.message_service import MessageService guidance_text = f"""Somnium Post-Installation Required

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

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

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

You can also refer to the Somnium installation guide at:
https://wiki.scenicroute.games/Somnium/1_Installation.html
""" MessageService.information(self, "Somnium Setup Required", guidance_text) # Reset the guidance flag self._show_somnium_guidance = False self._somnium_install_dir = None def cancel_and_cleanup(self): """Handle Cancel button - clean up processes and go back""" self.collapse_show_details_before_leave() self.go_back() QTimer.singleShot(0, self.cleanup_processes) def reset_screen_to_defaults(self): """Reset the screen to default state when navigating back from main menu""" # Reset form fields self.file_edit.setText("") self.install_dir_edit.setText(self.config_handler.get_modlist_install_base_dir()) # Clear console and process monitor self.console.clear() self.process_monitor.clear() # Re-enable controls (in case they were disabled from previous errors) self._enable_controls_after_operation() # Check requirements when screen is actually shown (not on app startup) self.check_requirements()