mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-01-17 11:37:01 +01:00
3577 lines
170 KiB
Python
3577 lines
170 KiB
Python
|
|
"""
|
|
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
|
|
|
|
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(QWidget):
|
|
steam_restart_finished = Signal(bool, str)
|
|
resize_request = Signal(str)
|
|
integration_complete = Signal(bool, str) # Signal for modlist integration completion (success, ttw_version)
|
|
|
|
def __init__(self, stacked_widget=None, main_menu_index=0, system_info=None):
|
|
super().__init__()
|
|
self.stacked_widget = stacked_widget
|
|
self.main_menu_index = main_menu_index
|
|
self.system_info = system_info
|
|
self.debug = DEBUG_BORDERS
|
|
self.online_modlists = {} # {game_type: [modlist_dict, ...]}
|
|
self.modlist_details = {} # {modlist_name: modlist_dict}
|
|
|
|
# Initialize log path (can be refreshed via refresh_paths method)
|
|
self.refresh_paths()
|
|
|
|
# Initialize services early
|
|
from jackify.backend.services.api_key_service import APIKeyService
|
|
from jackify.backend.services.resolution_service import ResolutionService
|
|
from jackify.backend.services.protontricks_detection_service import ProtontricksDetectionService
|
|
from jackify.backend.handlers.config_handler import ConfigHandler
|
|
self.api_key_service = APIKeyService()
|
|
self.resolution_service = ResolutionService()
|
|
self.config_handler = ConfigHandler()
|
|
self.protontricks_service = ProtontricksDetectionService()
|
|
|
|
# Modlist integration mode tracking
|
|
self._integration_mode = False
|
|
self._integration_modlist_name = None
|
|
self._integration_install_dir = None
|
|
|
|
# Somnium guidance tracking
|
|
self._show_somnium_guidance = False
|
|
self._somnium_install_dir = None
|
|
|
|
# Scroll tracking for professional auto-scroll behavior
|
|
self._user_manually_scrolled = False
|
|
self._was_at_bottom = True
|
|
|
|
# Initialize Wabbajack parser for game detection
|
|
self.wabbajack_parser = WabbajackParser()
|
|
# Remember original main window geometry/min-size to restore on expand
|
|
self._saved_geometry = None
|
|
self._saved_min_size = None
|
|
|
|
main_overall_vbox = QVBoxLayout(self)
|
|
main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
|
|
# Match other workflow screens
|
|
main_overall_vbox.setContentsMargins(50, 50, 50, 0)
|
|
main_overall_vbox.setSpacing(12)
|
|
if self.debug:
|
|
self.setStyleSheet("border: 2px solid magenta;")
|
|
|
|
# --- Header (title, description) ---
|
|
header_widget = QWidget()
|
|
header_layout = QVBoxLayout()
|
|
header_layout.setContentsMargins(0, 0, 0, 0)
|
|
header_layout.setSpacing(2)
|
|
|
|
# Title
|
|
title = QLabel("<b>Install Tale of Two Wastelands (TTW)</b>")
|
|
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};")
|
|
title.setAlignment(Qt.AlignHCenter)
|
|
header_layout.addWidget(title)
|
|
|
|
header_layout.addSpacing(10)
|
|
|
|
# Description area with fixed height
|
|
desc = QLabel(
|
|
"This screen allows you to install Tale of Two Wastelands (TTW) using TTW_Linux_Installer. "
|
|
"Configure your options and start the installation."
|
|
)
|
|
desc.setWordWrap(True)
|
|
desc.setStyleSheet("color: #ccc; font-size: 13px;")
|
|
desc.setAlignment(Qt.AlignHCenter)
|
|
desc.setMaximumHeight(50) # Fixed height for description zone
|
|
header_layout.addWidget(desc)
|
|
|
|
header_layout.addSpacing(12)
|
|
|
|
header_widget.setLayout(header_layout)
|
|
header_widget.setFixedHeight(120) # Fixed total header height to match other screens
|
|
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)
|
|
user_config_vbox.setSpacing(4) # Reduce spacing between major form sections
|
|
|
|
# --- Instructions ---
|
|
instruction_text = QLabel(
|
|
"Tale of Two Wastelands installation requires a .mpi file you can get from: "
|
|
'<a href="https://mod.pub/ttw/133/files">https://mod.pub/ttw/133/files</a> '
|
|
"(requires a user account for mod.db)"
|
|
)
|
|
instruction_text.setWordWrap(True)
|
|
instruction_text.setStyleSheet("color: #ccc; font-size: 12px; margin: 0px; padding: 0px; line-height: 1.2;")
|
|
instruction_text.setOpenExternalLinks(True)
|
|
user_config_vbox.addWidget(instruction_text)
|
|
|
|
# --- Compact Form Grid for inputs (align with other screens) ---
|
|
form_grid = QGridLayout()
|
|
form_grid.setHorizontalSpacing(12)
|
|
form_grid.setVerticalSpacing(6)
|
|
form_grid.setContentsMargins(0, 0, 0, 0)
|
|
|
|
# Row 0: TTW .mpi File location
|
|
file_label = QLabel("TTW .mpi File location:")
|
|
self.file_edit = QLineEdit()
|
|
self.file_edit.setMaximumHeight(25)
|
|
self.file_edit.textChanged.connect(self._update_start_button_state)
|
|
self.file_btn = QPushButton("Browse")
|
|
self.file_btn.clicked.connect(self.browse_wabbajack_file)
|
|
file_hbox = QHBoxLayout()
|
|
file_hbox.addWidget(self.file_edit)
|
|
file_hbox.addWidget(self.file_btn)
|
|
form_grid.addWidget(file_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
|
|
form_grid.addLayout(file_hbox, 0, 1)
|
|
|
|
# Row 1: Output Directory
|
|
install_dir_label = QLabel("Output Directory:")
|
|
self.install_dir_edit = QLineEdit(self.config_handler.get_modlist_install_base_dir())
|
|
self.install_dir_edit.setMaximumHeight(25)
|
|
self.browse_install_btn = QPushButton("Browse")
|
|
self.browse_install_btn.clicked.connect(self.browse_install_dir)
|
|
install_dir_hbox = QHBoxLayout()
|
|
install_dir_hbox.addWidget(self.install_dir_edit)
|
|
install_dir_hbox.addWidget(self.browse_install_btn)
|
|
form_grid.addWidget(install_dir_label, 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
|
|
form_grid.addLayout(install_dir_hbox, 1, 1)
|
|
|
|
# --- TTW_Linux_Installer Status aligned in form grid (row 2) ---
|
|
ttw_installer_label = QLabel("TTW_Linux_Installer Status:")
|
|
self.ttw_installer_status = QLabel("Checking...")
|
|
self.ttw_installer_btn = QPushButton("Install now")
|
|
self.ttw_installer_btn.setStyleSheet("""
|
|
QPushButton:hover { opacity: 0.95; }
|
|
QPushButton:disabled { opacity: 0.6; }
|
|
""")
|
|
self.ttw_installer_btn.setVisible(False)
|
|
self.ttw_installer_btn.clicked.connect(self.install_ttw_installer)
|
|
ttw_installer_hbox = QHBoxLayout()
|
|
ttw_installer_hbox.setContentsMargins(0, 0, 0, 0)
|
|
ttw_installer_hbox.setSpacing(8)
|
|
ttw_installer_hbox.addWidget(self.ttw_installer_status)
|
|
ttw_installer_hbox.addWidget(self.ttw_installer_btn)
|
|
ttw_installer_hbox.addStretch()
|
|
form_grid.addWidget(ttw_installer_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
|
|
form_grid.addLayout(ttw_installer_hbox, 2, 1)
|
|
|
|
# --- Game Requirements aligned in form grid (row 3) ---
|
|
game_req_label = QLabel("Game Requirements:")
|
|
self.fallout3_status = QLabel("Fallout 3: Checking...")
|
|
self.fallout3_status.setStyleSheet("color: #ccc;")
|
|
self.fnv_status = QLabel("Fallout New Vegas: Checking...")
|
|
self.fnv_status.setStyleSheet("color: #ccc;")
|
|
game_req_hbox = QHBoxLayout()
|
|
game_req_hbox.setContentsMargins(0, 0, 0, 0)
|
|
game_req_hbox.setSpacing(16)
|
|
game_req_hbox.addWidget(self.fallout3_status)
|
|
game_req_hbox.addWidget(self.fnv_status)
|
|
game_req_hbox.addStretch()
|
|
form_grid.addWidget(game_req_label, 3, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
|
|
form_grid.addLayout(game_req_hbox, 3, 1)
|
|
|
|
form_group = QWidget()
|
|
form_group.setLayout(form_grid)
|
|
user_config_vbox.addWidget(form_group)
|
|
|
|
# (TTW_Linux_Installer and Game Requirements now aligned in form_grid above)
|
|
|
|
# --- Buttons ---
|
|
btn_row = QHBoxLayout()
|
|
btn_row.setAlignment(Qt.AlignHCenter)
|
|
self.start_btn = QPushButton("Start Installation")
|
|
self.start_btn.setEnabled(False) # Disabled until requirements are met
|
|
btn_row.addWidget(self.start_btn)
|
|
|
|
|
|
|
|
# Cancel button (goes back to menu)
|
|
self.cancel_btn = QPushButton("Cancel")
|
|
self.cancel_btn.clicked.connect(self.cancel_and_cleanup)
|
|
btn_row.addWidget(self.cancel_btn)
|
|
|
|
# Cancel Installation button (appears during installation)
|
|
self.cancel_install_btn = QPushButton("Cancel Installation")
|
|
self.cancel_install_btn.clicked.connect(self.cancel_installation)
|
|
self.cancel_install_btn.setVisible(False) # Hidden by default
|
|
btn_row.addWidget(self.cancel_install_btn)
|
|
|
|
# Add stretches to center buttons row
|
|
btn_row.insertStretch(0, 1)
|
|
btn_row.addStretch(1)
|
|
|
|
# Show Details Checkbox (collapsible console)
|
|
self.show_details_checkbox = QCheckBox("Show details")
|
|
# Start collapsed by default (console hidden until user opts in)
|
|
self.show_details_checkbox.setChecked(False)
|
|
self.show_details_checkbox.setToolTip("Toggle between activity summary and detailed console output")
|
|
# Use toggled(bool) for reliable signal and map to our handler
|
|
try:
|
|
self.show_details_checkbox.toggled.connect(self._on_show_details_toggled)
|
|
except Exception:
|
|
# Fallback to stateChanged if toggled is unavailable
|
|
self.show_details_checkbox.stateChanged.connect(self._toggle_console_visibility)
|
|
# Note: Checkbox will be placed in the status banner row (right-aligned)
|
|
|
|
# Wrap button row in widget for debug borders
|
|
btn_row_widget = QWidget()
|
|
btn_row_widget.setLayout(btn_row)
|
|
btn_row_widget.setMaximumHeight(50) # Limit height to make it more compact
|
|
if self.debug:
|
|
btn_row_widget.setStyleSheet("border: 2px solid red;")
|
|
btn_row_widget.setToolTip("BUTTON_ROW")
|
|
# Keep a reference for dynamic sizing when collapsing/expanding
|
|
self.btn_row_widget = btn_row_widget
|
|
user_config_widget = QWidget()
|
|
user_config_widget.setLayout(user_config_vbox)
|
|
user_config_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
|
|
if self.debug:
|
|
user_config_widget.setStyleSheet("border: 2px solid orange;")
|
|
user_config_widget.setToolTip("USER_CONFIG_WIDGET")
|
|
|
|
# Right: Tabbed interface with Activity and Process Monitor
|
|
# Both tabs are always available, user can switch between them
|
|
self.file_progress_list = FileProgressList()
|
|
self.file_progress_list.setMinimumSize(QSize(300, 20))
|
|
self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
|
|
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("<b>[Process Monitor]</b>")
|
|
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)
|
|
process_monitor_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
if self.debug:
|
|
process_monitor_widget.setStyleSheet("border: 2px solid purple;")
|
|
process_monitor_widget.setToolTip("PROCESS_MONITOR")
|
|
self.process_monitor_widget = process_monitor_widget
|
|
|
|
# Create tab widget to hold both Activity and Process Monitor
|
|
self.activity_tabs = QTabWidget()
|
|
self.activity_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }")
|
|
self.activity_tabs.setContentsMargins(0, 0, 0, 0)
|
|
self.activity_tabs.setDocumentMode(False)
|
|
self.activity_tabs.setTabPosition(QTabWidget.North)
|
|
if self.debug:
|
|
self.activity_tabs.setStyleSheet("border: 2px solid cyan;")
|
|
self.activity_tabs.setToolTip("ACTIVITY_TABS")
|
|
|
|
# Add both widgets as tabs
|
|
self.activity_tabs.addTab(self.file_progress_list, "Activity")
|
|
self.activity_tabs.addTab(process_monitor_widget, "Process Monitor")
|
|
|
|
upper_hbox.addWidget(user_config_widget, stretch=11)
|
|
upper_hbox.addWidget(self.activity_tabs, stretch=9)
|
|
upper_hbox.setAlignment(Qt.AlignTop)
|
|
self.upper_section_widget = QWidget()
|
|
self.upper_section_widget.setLayout(upper_hbox)
|
|
# Use Fixed size policy for consistent height
|
|
self.upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|
self.upper_section_widget.setMaximumHeight(280) # Fixed height to match other workflow screens
|
|
if self.debug:
|
|
self.upper_section_widget.setStyleSheet("border: 2px solid green;")
|
|
self.upper_section_widget.setToolTip("UPPER_SECTION")
|
|
main_overall_vbox.addWidget(self.upper_section_widget)
|
|
|
|
# --- Status Banner (shows high-level progress) ---
|
|
self.status_banner = QLabel("Ready to install")
|
|
self.status_banner.setAlignment(Qt.AlignCenter)
|
|
self.status_banner.setStyleSheet(f"""
|
|
background-color: #2a2a2a;
|
|
color: {JACKIFY_COLOR_BLUE};
|
|
padding: 6px 8px;
|
|
border-radius: 4px;
|
|
font-weight: bold;
|
|
font-size: 13px;
|
|
""")
|
|
# Prevent banner from expanding vertically
|
|
self.status_banner.setMaximumHeight(34)
|
|
self.status_banner.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
|
# Show the banner by default so users see status even when collapsed
|
|
self.status_banner.setVisible(True)
|
|
# Create a compact banner row with the checkbox right-aligned
|
|
banner_row = QHBoxLayout()
|
|
# Minimal padding to avoid visible gaps
|
|
banner_row.setContentsMargins(0, 0, 0, 0)
|
|
banner_row.setSpacing(8)
|
|
banner_row.addWidget(self.status_banner, 1)
|
|
banner_row.addStretch()
|
|
banner_row.addWidget(self.show_details_checkbox)
|
|
banner_row_widget = QWidget()
|
|
banner_row_widget.setLayout(banner_row)
|
|
banner_row_widget.setMaximumHeight(45) # Compact height
|
|
banner_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
|
main_overall_vbox.addWidget(banner_row_widget)
|
|
|
|
# Remove spacing - console should expand to fill available space
|
|
# --- Console output area (full width, placeholder for now) ---
|
|
self.console = QTextEdit()
|
|
self.console.setReadOnly(True)
|
|
self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
|
|
# Console starts hidden; toggled via Show details
|
|
self.console.setMinimumHeight(0)
|
|
self.console.setMaximumHeight(0)
|
|
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()
|
|
|
|
# Add console directly so we can hide/show without affecting buttons
|
|
main_overall_vbox.addWidget(self.console, stretch=1)
|
|
# Place the button row after the console so it's always visible and centered
|
|
main_overall_vbox.addWidget(btn_row_widget, alignment=Qt.AlignHCenter)
|
|
|
|
# Store reference to main layout
|
|
self.main_overall_vbox = main_overall_vbox
|
|
self.setLayout(main_overall_vbox)
|
|
|
|
self.current_modlists = []
|
|
|
|
# --- Process Monitor (right) ---
|
|
self.process = None
|
|
self.log_timer = None
|
|
self.last_log_pos = 0
|
|
# --- Process Monitor Timer ---
|
|
self.top_timer = QTimer(self)
|
|
self.top_timer.timeout.connect(self.update_top_panel)
|
|
self.top_timer.start(2000)
|
|
# --- Start Installation button ---
|
|
self.start_btn.clicked.connect(self.validate_and_start_install)
|
|
self.steam_restart_finished.connect(self._on_steam_restart_finished)
|
|
|
|
# Initialize process tracking
|
|
self.process = None
|
|
|
|
# Initialize empty controls list - will be populated after UI is built
|
|
self._actionable_controls = []
|
|
|
|
def check_requirements(self):
|
|
"""Check and display requirements status"""
|
|
from jackify.backend.handlers.path_handler import PathHandler
|
|
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
|
from jackify.backend.handlers.config_handler import ConfigHandler
|
|
from jackify.backend.models.configuration import SystemInfo
|
|
|
|
path_handler = PathHandler()
|
|
|
|
# Check game detection
|
|
detected_games = path_handler.find_vanilla_game_paths()
|
|
|
|
# Fallout 3
|
|
if 'Fallout 3' in detected_games:
|
|
self.fallout3_status.setText("Fallout 3: Detected")
|
|
self.fallout3_status.setStyleSheet("color: #3fd0ea;")
|
|
else:
|
|
self.fallout3_status.setText("Fallout 3: Not Found - Install from Steam")
|
|
self.fallout3_status.setStyleSheet("color: #f44336;")
|
|
|
|
# Fallout New Vegas
|
|
if 'Fallout New Vegas' in detected_games:
|
|
self.fnv_status.setText("Fallout New Vegas: Detected")
|
|
self.fnv_status.setStyleSheet("color: #3fd0ea;")
|
|
else:
|
|
self.fnv_status.setText("Fallout New Vegas: Not Found - Install from Steam")
|
|
self.fnv_status.setStyleSheet("color: #f44336;")
|
|
|
|
# Update Start button state after checking requirements
|
|
self._update_start_button_state()
|
|
|
|
def _check_ttw_installer_status(self):
|
|
"""Check TTW_Linux_Installer installation status and update UI"""
|
|
try:
|
|
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
|
|
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
|
from jackify.backend.handlers.config_handler import ConfigHandler
|
|
from jackify.backend.models.configuration import SystemInfo
|
|
|
|
# Create handler instances
|
|
filesystem_handler = FileSystemHandler()
|
|
config_handler = ConfigHandler()
|
|
system_info = SystemInfo(is_steamdeck=False)
|
|
ttw_installer_handler = TTWInstallerHandler(
|
|
steamdeck=False,
|
|
verbose=False,
|
|
filesystem_handler=filesystem_handler,
|
|
config_handler=config_handler
|
|
)
|
|
|
|
# Check if TTW_Linux_Installer is installed
|
|
ttw_installer_handler._check_installation()
|
|
|
|
if ttw_installer_handler.ttw_installer_installed:
|
|
# Check version against latest
|
|
update_available, installed_v, latest_v = ttw_installer_handler.is_ttw_installer_update_available()
|
|
if update_available:
|
|
version_text = f"Out of date (v{installed_v} → v{latest_v})" if installed_v and latest_v else "Out of date"
|
|
self.ttw_installer_status.setText(version_text)
|
|
self.ttw_installer_status.setStyleSheet("color: #f44336;")
|
|
self.ttw_installer_btn.setText("Update now")
|
|
self.ttw_installer_btn.setEnabled(True)
|
|
self.ttw_installer_btn.setVisible(True)
|
|
else:
|
|
version_text = f"Ready (v{installed_v})" if installed_v else "Ready"
|
|
self.ttw_installer_status.setText(version_text)
|
|
self.ttw_installer_status.setStyleSheet("color: #3fd0ea;")
|
|
self.ttw_installer_btn.setText("Update now")
|
|
self.ttw_installer_btn.setEnabled(False) # Greyed out when ready
|
|
self.ttw_installer_btn.setVisible(True)
|
|
else:
|
|
self.ttw_installer_status.setText("Not Found")
|
|
self.ttw_installer_status.setStyleSheet("color: #f44336;")
|
|
self.ttw_installer_btn.setText("Install now")
|
|
self.ttw_installer_btn.setEnabled(True)
|
|
self.ttw_installer_btn.setVisible(True)
|
|
|
|
except Exception as e:
|
|
self.ttw_installer_status.setText("Check Failed")
|
|
self.ttw_installer_status.setStyleSheet("color: #f44336;")
|
|
self.ttw_installer_btn.setText("Install now")
|
|
self.ttw_installer_btn.setEnabled(True)
|
|
self.ttw_installer_btn.setVisible(True)
|
|
debug_print(f"DEBUG: TTW_Linux_Installer status check failed: {e}")
|
|
|
|
def install_ttw_installer(self):
|
|
"""Install or update TTW_Linux_Installer"""
|
|
# If not detected, show info dialog
|
|
try:
|
|
current_status = self.ttw_installer_status.text().strip()
|
|
except Exception:
|
|
current_status = ""
|
|
if current_status == "Not Found":
|
|
MessageService.information(
|
|
self,
|
|
"TTW_Linux_Installer Installation",
|
|
(
|
|
"TTW_Linux_Installer is a native Linux installer for TTW and other MPI packages.<br><br>"
|
|
"Project: <a href=\"https://github.com/SulfurNitride/TTW_Linux_Installer\">github.com/SulfurNitride/TTW_Linux_Installer</a><br>"
|
|
"Please star the repository and thank the developer.<br><br>"
|
|
"Jackify will now download and install the latest Linux build of TTW_Linux_Installer."
|
|
),
|
|
safety_level="low",
|
|
)
|
|
|
|
# Update button to show installation in progress
|
|
self.ttw_installer_btn.setText("Installing...")
|
|
self.ttw_installer_btn.setEnabled(False)
|
|
|
|
self.console.append("Installing/updating TTW_Linux_Installer...")
|
|
|
|
# Create background thread for installation
|
|
from PySide6.QtCore import QThread, Signal
|
|
|
|
class InstallerDownloadThread(QThread):
|
|
finished = Signal(bool, str) # success, message
|
|
progress = Signal(str) # progress message
|
|
|
|
def run(self):
|
|
try:
|
|
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
|
|
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
|
from jackify.backend.handlers.config_handler import ConfigHandler
|
|
from jackify.backend.models.configuration import SystemInfo
|
|
|
|
# Create handler instances
|
|
filesystem_handler = FileSystemHandler()
|
|
config_handler = ConfigHandler()
|
|
system_info = SystemInfo(is_steamdeck=False)
|
|
ttw_installer_handler = TTWInstallerHandler(
|
|
steamdeck=False,
|
|
verbose=False,
|
|
filesystem_handler=filesystem_handler,
|
|
config_handler=config_handler
|
|
)
|
|
|
|
# Install TTW_Linux_Installer (this will download and extract)
|
|
self.progress.emit("Downloading TTW_Linux_Installer...")
|
|
success, message = ttw_installer_handler.install_ttw_installer()
|
|
|
|
if success:
|
|
install_path = ttw_installer_handler.ttw_installer_dir
|
|
self.progress.emit(f"Installation complete: {install_path}")
|
|
else:
|
|
self.progress.emit(f"Installation failed: {message}")
|
|
|
|
self.finished.emit(success, message)
|
|
|
|
except Exception as e:
|
|
error_msg = f"Error installing TTW_Linux_Installer: {str(e)}"
|
|
self.progress.emit(error_msg)
|
|
debug_print(f"DEBUG: TTW_Linux_Installer installation error: {e}")
|
|
self.finished.emit(False, error_msg)
|
|
|
|
# Create and start thread
|
|
self.installer_download_thread = InstallerDownloadThread()
|
|
self.installer_download_thread.progress.connect(self._on_installer_download_progress)
|
|
self.installer_download_thread.finished.connect(self._on_installer_download_finished)
|
|
self.installer_download_thread.start()
|
|
|
|
# Update Activity window to show download in progress
|
|
self.file_progress_list.clear()
|
|
self.file_progress_list.update_or_add_item(
|
|
item_id="ttw_installer_download",
|
|
label="Downloading TTW_Linux_Installer...",
|
|
progress=0
|
|
)
|
|
|
|
def _on_installer_download_progress(self, message):
|
|
"""Handle installer download progress updates"""
|
|
self.console.append(message)
|
|
# Update Activity window based on progress message
|
|
if "Downloading" in message:
|
|
self.file_progress_list.update_or_add_item(
|
|
item_id="ttw_installer_download",
|
|
label="Downloading TTW_Linux_Installer...",
|
|
progress=0 # Indeterminate progress
|
|
)
|
|
elif "Extracting" in message or "extracting" in message.lower():
|
|
self.file_progress_list.update_or_add_item(
|
|
item_id="ttw_installer_download",
|
|
label="Extracting TTW_Linux_Installer...",
|
|
progress=50
|
|
)
|
|
elif "complete" in message.lower() or "successfully" in message.lower():
|
|
self.file_progress_list.update_or_add_item(
|
|
item_id="ttw_installer_download",
|
|
label="TTW_Linux_Installer ready",
|
|
progress=100
|
|
)
|
|
|
|
def _on_installer_download_finished(self, success, message):
|
|
"""Handle installer download completion"""
|
|
if success:
|
|
self.console.append("TTW_Linux_Installer installed successfully")
|
|
# Clear Activity window after successful installation
|
|
self.file_progress_list.clear()
|
|
# Re-check status after installation (this will update button state correctly)
|
|
self._check_ttw_installer_status()
|
|
self._update_start_button_state()
|
|
else:
|
|
self.console.append(f"Installation failed: {message}")
|
|
# Clear Activity window on failure
|
|
self.file_progress_list.clear()
|
|
# Re-enable button on failure so user can retry
|
|
self.ttw_installer_btn.setText("Install now")
|
|
self.ttw_installer_btn.setEnabled(True)
|
|
|
|
def _check_ttw_requirements(self):
|
|
"""Check TTW requirements before installation"""
|
|
from jackify.backend.handlers.path_handler import PathHandler
|
|
|
|
path_handler = PathHandler()
|
|
|
|
# Check game detection
|
|
detected_games = path_handler.find_vanilla_game_paths()
|
|
missing_games = []
|
|
|
|
if 'Fallout 3' not in detected_games:
|
|
missing_games.append("Fallout 3")
|
|
if 'Fallout New Vegas' not in detected_games:
|
|
missing_games.append("Fallout New Vegas")
|
|
|
|
if missing_games:
|
|
MessageService.warning(
|
|
self,
|
|
"Missing Required Games",
|
|
f"TTW requires both Fallout 3 and Fallout New Vegas to be installed.\n\nMissing: {', '.join(missing_games)}"
|
|
)
|
|
return False
|
|
|
|
# Check TTW_Linux_Installer using the status we already checked
|
|
status_text = self.ttw_installer_status.text()
|
|
if status_text in ("Not Found", "Check Failed"):
|
|
MessageService.warning(
|
|
self,
|
|
"TTW_Linux_Installer Required",
|
|
"TTW_Linux_Installer is required for TTW installation but is not installed.\n\nPlease install TTW_Linux_Installer using the 'Install now' button."
|
|
)
|
|
return False
|
|
|
|
return True
|
|
|
|
# Now collect all actionable controls after UI is fully built
|
|
self._collect_actionable_controls()
|
|
|
|
# Check if all requirements are met and enable/disable Start button
|
|
self._update_start_button_state()
|
|
|
|
def _update_start_button_state(self):
|
|
"""Enable/disable Start button based on requirements and file selection"""
|
|
# Check if all requirements are met
|
|
requirements_met = self._check_ttw_requirements()
|
|
|
|
# Check if .mpi file is selected
|
|
mpi_file_selected = bool(self.file_edit.text().strip())
|
|
|
|
# Enable Start button only if both requirements are met and file is selected
|
|
self.start_btn.setEnabled(requirements_met and mpi_file_selected)
|
|
|
|
# Update button text to indicate what's missing
|
|
if not requirements_met:
|
|
self.start_btn.setText("Requirements Not Met")
|
|
elif not mpi_file_selected:
|
|
self.start_btn.setText("Select TTW .mpi File")
|
|
else:
|
|
self.start_btn.setText("Start Installation")
|
|
|
|
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 set_modlist_integration_mode(self, modlist_name: str, install_dir: str):
|
|
"""Set the screen to modlist integration mode
|
|
|
|
This mode is activated when TTW needs to be installed and integrated
|
|
into an existing modlist. In this mode, after TTW installation completes,
|
|
the TTW output will be automatically integrated into the modlist.
|
|
|
|
Args:
|
|
modlist_name: Name of the modlist to integrate TTW into
|
|
install_dir: Installation directory of the modlist
|
|
"""
|
|
self._integration_mode = True
|
|
self._integration_modlist_name = modlist_name
|
|
self._integration_install_dir = install_dir
|
|
|
|
# Reset saved geometry so showEvent can properly collapse from current window size
|
|
self._saved_geometry = None
|
|
self._saved_min_size = None
|
|
|
|
# Update UI to show integration mode
|
|
debug_print(f"TTW screen set to integration mode for modlist: {modlist_name}")
|
|
debug_print(f"Installation directory: {install_dir}")
|
|
|
|
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 force_collapsed_state(self):
|
|
"""Force the screen into its collapsed state regardless of prior layout.
|
|
|
|
This is used to resolve timing/race conditions when navigating here from
|
|
the end of the Install Modlist workflow, ensuring the UI opens collapsed
|
|
just like when launched from Additional Tasks.
|
|
"""
|
|
try:
|
|
from PySide6.QtCore import Qt as _Qt
|
|
# Ensure checkbox is unchecked without emitting user-facing signals
|
|
if self.show_details_checkbox.isChecked():
|
|
self.show_details_checkbox.blockSignals(True)
|
|
self.show_details_checkbox.setChecked(False)
|
|
self.show_details_checkbox.blockSignals(False)
|
|
# Apply collapsed layout explicitly
|
|
self._toggle_console_visibility(_Qt.Unchecked)
|
|
# Inform parent window to collapse height
|
|
try:
|
|
self.resize_request.emit('collapse')
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
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
|
|
# Only enforce a small minimum when details are shown; keep 0 when collapsed
|
|
if self.console.isVisible():
|
|
self.console.setMinimumHeight(50)
|
|
else:
|
|
self.console.setMinimumHeight(0)
|
|
|
|
def showEvent(self, event):
|
|
"""Called when the widget becomes visible"""
|
|
super().showEvent(event)
|
|
debug_print(f"DEBUG: TTW showEvent - integration_mode={self._integration_mode}")
|
|
|
|
# Check TTW_Linux_Installer status asynchronously (non-blocking) after screen opens
|
|
from PySide6.QtCore import QTimer
|
|
QTimer.singleShot(0, self._check_ttw_installer_status)
|
|
|
|
# Ensure initial collapsed layout each time this screen is opened
|
|
try:
|
|
from PySide6.QtCore import Qt as _Qt
|
|
# On Steam Deck: keep expanded layout and hide the details toggle
|
|
try:
|
|
is_steamdeck = False
|
|
# Check our own system_info first
|
|
if self.system_info and getattr(self.system_info, 'is_steamdeck', False):
|
|
is_steamdeck = True
|
|
# Fallback to checking parent window's system_info
|
|
elif not self.system_info:
|
|
parent = self.window()
|
|
if parent and hasattr(parent, 'system_info') and getattr(parent.system_info, 'is_steamdeck', False):
|
|
is_steamdeck = True
|
|
|
|
if is_steamdeck:
|
|
debug_print("DEBUG: Steam Deck detected, keeping expanded")
|
|
# Force expanded state and hide checkbox
|
|
if self.show_details_checkbox.isVisible():
|
|
self.show_details_checkbox.setVisible(False)
|
|
# Show console with proper sizing for Steam Deck
|
|
self.console.setVisible(True)
|
|
self.console.show()
|
|
self.console.setMinimumHeight(200)
|
|
self.console.setMaximumHeight(16777215) # Remove height limit
|
|
return
|
|
except Exception as e:
|
|
debug_print(f"DEBUG: Steam Deck check exception: {e}")
|
|
pass
|
|
debug_print(f"DEBUG: Checkbox checked={self.show_details_checkbox.isChecked()}")
|
|
if self.show_details_checkbox.isChecked():
|
|
self.show_details_checkbox.blockSignals(True)
|
|
self.show_details_checkbox.setChecked(False)
|
|
self.show_details_checkbox.blockSignals(False)
|
|
|
|
debug_print("DEBUG: Calling _toggle_console_visibility(Unchecked)")
|
|
self._toggle_console_visibility(_Qt.Unchecked)
|
|
# Force the window to compact height to eliminate bottom whitespace
|
|
main_window = self.window()
|
|
debug_print(f"DEBUG: main_window={main_window}, size={main_window.size() if main_window else None}")
|
|
if main_window:
|
|
# Save original geometry once
|
|
if self._saved_geometry is None:
|
|
self._saved_geometry = main_window.geometry()
|
|
debug_print(f"DEBUG: Saved geometry: {self._saved_geometry}")
|
|
if self._saved_min_size is None:
|
|
self._saved_min_size = main_window.minimumSize()
|
|
debug_print(f"DEBUG: Saved min size: {self._saved_min_size}")
|
|
|
|
# Fixed compact size - same as menu screens
|
|
from PySide6.QtCore import QSize
|
|
# On Steam Deck, keep fullscreen; on other systems, set normal window state
|
|
if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck):
|
|
main_window.showNormal()
|
|
# First, completely unlock the window
|
|
main_window.setMinimumSize(QSize(0, 0))
|
|
main_window.setMaximumSize(QSize(16777215, 16777215))
|
|
# Only set minimum size - DO NOT RESIZE
|
|
set_responsive_minimum(main_window, min_width=960, min_height=420)
|
|
# DO NOT resize - let window stay at current size
|
|
# Notify parent to ensure compact
|
|
try:
|
|
self.resize_request.emit('collapse')
|
|
debug_print("DEBUG: Emitted resize_request collapse signal")
|
|
except Exception as e:
|
|
debug_print(f"DEBUG: Exception emitting signal: {e}")
|
|
pass
|
|
except Exception as e:
|
|
debug_print(f"DEBUG: showEvent exception: {e}")
|
|
import traceback
|
|
debug_print(f"DEBUG: {traceback.format_exc()}")
|
|
pass
|
|
|
|
def hideEvent(self, event):
|
|
"""Called when the widget becomes hidden - restore window size constraints"""
|
|
super().hideEvent(event)
|
|
try:
|
|
main_window = self.window()
|
|
if main_window:
|
|
from PySide6.QtCore import QSize
|
|
# Clear any size constraints that might have been set to prevent affecting other screens
|
|
# This is especially important when the console is expanded
|
|
main_window.setMaximumSize(QSize(16777215, 16777215))
|
|
main_window.setMinimumSize(QSize(0, 0))
|
|
debug_print("DEBUG: Install TTW hideEvent - cleared window size constraints")
|
|
except Exception as e:
|
|
debug_print(f"DEBUG: hideEvent exception: {e}")
|
|
pass
|
|
|
|
def _load_saved_parent_directories(self):
|
|
"""No-op: do not pre-populate install/download directories from saved values."""
|
|
pass
|
|
|
|
def _update_directory_suggestions(self, modlist_name):
|
|
"""Update directory suggestions based on modlist name"""
|
|
try:
|
|
if not modlist_name:
|
|
return
|
|
|
|
# Update install directory suggestion with modlist name
|
|
saved_install_parent = self.config_handler.get_default_install_parent_dir()
|
|
if saved_install_parent:
|
|
suggested_install_dir = os.path.join(saved_install_parent, modlist_name)
|
|
self.install_dir_edit.setText(suggested_install_dir)
|
|
debug_print(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}")
|
|
|
|
# Update download directory suggestion
|
|
saved_download_parent = self.config_handler.get_default_download_parent_dir()
|
|
if saved_download_parent:
|
|
suggested_download_dir = os.path.join(saved_download_parent, "Downloads")
|
|
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 go_back(self):
|
|
"""Navigate back to main menu and restore window size"""
|
|
# Restore window size before navigating away
|
|
try:
|
|
main_window = self.window()
|
|
if main_window:
|
|
from PySide6.QtCore import QSize
|
|
|
|
# Only set minimum size - DO NOT RESIZE
|
|
main_window.setMaximumSize(QSize(16777215, 16777215))
|
|
set_responsive_minimum(main_window, min_width=960, min_height=420)
|
|
# DO NOT resize - let window stay at current size
|
|
except Exception:
|
|
pass
|
|
|
|
if self.stacked_widget:
|
|
self.stacked_widget.setCurrentIndex(self.main_menu_index)
|
|
|
|
def update_top_panel(self):
|
|
try:
|
|
result = subprocess.run([
|
|
"ps", "-eo", "pcpu,pmem,comm,args"
|
|
], stdout=subprocess.PIPE, text=True, timeout=2)
|
|
lines = result.stdout.splitlines()
|
|
header = "CPU%\tMEM%\tCOMMAND"
|
|
filtered = [header]
|
|
process_rows = []
|
|
for line in lines[1:]:
|
|
line_lower = line.lower()
|
|
if (
|
|
("jackify-engine" in line_lower or "7zz" in line_lower or "texconv" in line_lower or
|
|
"wine" in line_lower or "wine64" in line_lower or "protontricks" in line_lower or
|
|
"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 validate_and_start_install(self):
|
|
import time
|
|
self._install_workflow_start_time = time.time()
|
|
debug_print('DEBUG: validate_and_start_install called')
|
|
|
|
# Reload config to pick up any settings changes made in Settings dialog
|
|
self.config_handler.reload_config()
|
|
debug_print('DEBUG: Reloaded config from disk')
|
|
|
|
# Check TTW requirements first
|
|
if not self._check_ttw_requirements():
|
|
return
|
|
|
|
# Check protontricks before proceeding
|
|
if not self._check_protontricks():
|
|
return
|
|
|
|
# Disable all controls during installation (except Cancel)
|
|
self._disable_controls_during_operation()
|
|
|
|
try:
|
|
# TTW only needs .mpi file
|
|
mpi_path = self.file_edit.text().strip()
|
|
if not mpi_path or not os.path.isfile(mpi_path) or not mpi_path.endswith('.mpi'):
|
|
MessageService.warning(self, "Invalid TTW File", "Please select a valid TTW .mpi file.")
|
|
self._enable_controls_after_operation()
|
|
return
|
|
install_dir = self.install_dir_edit.text().strip()
|
|
|
|
# Validate required fields
|
|
missing_fields = []
|
|
if not install_dir:
|
|
missing_fields.append("Install Directory")
|
|
if missing_fields:
|
|
MessageService.warning(self, "Missing Required Fields", f"Please fill in all required fields before starting the install:\n- " + "\n- ".join(missing_fields))
|
|
self._enable_controls_after_operation()
|
|
return
|
|
|
|
# Validate install directory
|
|
validation_handler = ValidationHandler()
|
|
from pathlib import Path
|
|
install_dir_path = Path(install_dir)
|
|
|
|
# Check for dangerous directories first (system roots, etc.)
|
|
if validation_handler.is_dangerous_directory(install_dir_path):
|
|
dlg = WarningDialog(
|
|
f"The directory '{install_dir}' is a system or user root and cannot be used for TTW installation.",
|
|
parent=self
|
|
)
|
|
if not dlg.exec() or not dlg.confirmed:
|
|
self._enable_controls_after_operation()
|
|
return
|
|
|
|
# Check if directory exists and is not empty - TTW_Linux_Installer will overwrite existing files
|
|
if install_dir_path.exists() and install_dir_path.is_dir():
|
|
# Check if directory contains any files
|
|
try:
|
|
has_files = any(install_dir_path.iterdir())
|
|
if has_files:
|
|
# Directory exists and is not empty - warn user about deletion
|
|
dlg = WarningDialog(
|
|
f"The TTW output directory already exists and contains files:\n{install_dir}\n\n"
|
|
f"All files in this directory will be deleted before installation.\n\n"
|
|
f"This action cannot be undone.",
|
|
parent=self
|
|
)
|
|
if not dlg.exec() or not dlg.confirmed:
|
|
self._enable_controls_after_operation()
|
|
return
|
|
|
|
# User confirmed - delete all contents of the directory
|
|
import shutil
|
|
try:
|
|
for item in install_dir_path.iterdir():
|
|
if item.is_dir():
|
|
shutil.rmtree(item)
|
|
else:
|
|
item.unlink()
|
|
debug_print(f"DEBUG: Deleted all contents of {install_dir}")
|
|
except Exception as e:
|
|
MessageService.critical(self, "Error", f"Failed to delete directory contents:\n{e}")
|
|
self._enable_controls_after_operation()
|
|
return
|
|
except Exception as e:
|
|
debug_print(f"DEBUG: Error checking directory contents: {e}")
|
|
# If we can't check, proceed
|
|
|
|
if not os.path.isdir(install_dir):
|
|
create = MessageService.question(self, "Create Directory?",
|
|
f"The install directory does not exist:\n{install_dir}\n\nWould you like to create it?",
|
|
critical=False # Non-critical, won't steal focus
|
|
)
|
|
if create == QMessageBox.Yes:
|
|
try:
|
|
os.makedirs(install_dir, exist_ok=True)
|
|
except Exception as e:
|
|
MessageService.critical(self, "Error", f"Failed to create install directory:\n{e}")
|
|
self._enable_controls_after_operation()
|
|
return
|
|
else:
|
|
self._enable_controls_after_operation()
|
|
return
|
|
|
|
# Start TTW installation
|
|
self.console.clear()
|
|
self.process_monitor.clear()
|
|
|
|
# Update button states for installation
|
|
self.start_btn.setEnabled(False)
|
|
self.cancel_btn.setVisible(False)
|
|
self.cancel_install_btn.setVisible(True)
|
|
|
|
debug_print(f'DEBUG: Calling run_ttw_installer with mpi_path={mpi_path}, install_dir={install_dir}')
|
|
self.run_ttw_installer(mpi_path, install_dir)
|
|
except Exception as e:
|
|
debug_print(f"DEBUG: Exception in validate_and_start_install: {e}")
|
|
import traceback
|
|
debug_print(f"DEBUG: Traceback: {traceback.format_exc()}")
|
|
# Re-enable all controls after exception
|
|
self._enable_controls_after_operation()
|
|
self.cancel_btn.setVisible(True)
|
|
self.cancel_install_btn.setVisible(False)
|
|
debug_print(f"DEBUG: Controls re-enabled in exception handler")
|
|
|
|
def run_ttw_installer(self, mpi_path, install_dir):
|
|
debug_print('DEBUG: run_ttw_installer called - USING THREADED BACKEND WRAPPER')
|
|
|
|
# CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog
|
|
# This ensures Proton version and winetricks settings are current
|
|
self.config_handler._load_config()
|
|
|
|
# Rotate log file at start of each workflow run (keep 5 backups)
|
|
from jackify.backend.handlers.logging_handler import LoggingHandler
|
|
from pathlib import Path
|
|
log_handler = LoggingHandler()
|
|
log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5)
|
|
|
|
# Clear console for fresh installation output
|
|
self.console.clear()
|
|
self._safe_append_text("Starting TTW installation...")
|
|
|
|
# Initialize Activity window with immediate feedback
|
|
self.file_progress_list.clear()
|
|
self._update_ttw_phase("Initializing TTW installation", 0, 0, 0)
|
|
# Force UI update immediately
|
|
QApplication.processEvents()
|
|
|
|
# Show status banner and show details checkbox
|
|
self.status_banner.setVisible(True)
|
|
self.status_banner.setText("Initializing TTW installation...")
|
|
self.show_details_checkbox.setVisible(True)
|
|
|
|
# Reset banner to default blue color for new installation
|
|
self.status_banner.setStyleSheet(f"""
|
|
background-color: #2a2a2a;
|
|
color: {JACKIFY_COLOR_BLUE};
|
|
padding: 8px;
|
|
border-radius: 4px;
|
|
font-weight: bold;
|
|
font-size: 13px;
|
|
""")
|
|
|
|
self.ttw_start_time = time.time()
|
|
|
|
# Start a timer to update elapsed time
|
|
self.ttw_elapsed_timer = QTimer()
|
|
self.ttw_elapsed_timer.timeout.connect(self._update_ttw_elapsed_time)
|
|
self.ttw_elapsed_timer.start(1000) # Update every second
|
|
|
|
# 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 TTWInstallationThread(QThread):
|
|
output_batch_received = Signal(list) # Batched output lines
|
|
progress_received = Signal(str)
|
|
installation_finished = Signal(bool, str)
|
|
|
|
def __init__(self, mpi_path, install_dir):
|
|
super().__init__()
|
|
self.mpi_path = mpi_path
|
|
self.install_dir = install_dir
|
|
self.cancelled = False
|
|
self.proc = None
|
|
self.output_buffer = [] # Buffer for batching output
|
|
self.last_emit_time = 0 # Track when we last emitted
|
|
|
|
def cancel(self):
|
|
self.cancelled = True
|
|
try:
|
|
if self.proc and self.proc.poll() is None:
|
|
self.proc.terminate()
|
|
except Exception:
|
|
pass
|
|
|
|
def process_and_buffer_line(self, raw_line):
|
|
"""Process line in worker thread and add to buffer"""
|
|
# Strip ANSI codes
|
|
cleaned = strip_ansi_control_codes(raw_line).strip()
|
|
|
|
# Strip emojis (do this in worker thread, not UI thread)
|
|
filtered_chars = []
|
|
for char in cleaned:
|
|
code = ord(char)
|
|
is_emoji = (
|
|
(0x1F300 <= code <= 0x1F9FF) or
|
|
(0x1F600 <= code <= 0x1F64F) or
|
|
(0x2600 <= code <= 0x26FF) or
|
|
(0x2700 <= code <= 0x27BF)
|
|
)
|
|
if not is_emoji:
|
|
filtered_chars.append(char)
|
|
cleaned = ''.join(filtered_chars).strip()
|
|
|
|
# Only buffer non-empty lines
|
|
if cleaned:
|
|
self.output_buffer.append(cleaned)
|
|
|
|
def flush_output_buffer(self):
|
|
"""Emit buffered lines as a batch"""
|
|
if self.output_buffer:
|
|
self.output_batch_received.emit(self.output_buffer[:])
|
|
self.output_buffer.clear()
|
|
self.last_emit_time = time.time()
|
|
|
|
def run(self):
|
|
try:
|
|
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
|
|
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
|
from jackify.backend.handlers.config_handler import ConfigHandler
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
# Emit startup message
|
|
self.process_and_buffer_line("Initializing TTW installation...")
|
|
self.flush_output_buffer()
|
|
|
|
# Create backend handler
|
|
filesystem_handler = FileSystemHandler()
|
|
config_handler = ConfigHandler()
|
|
ttw_handler = TTWInstallerHandler(
|
|
steamdeck=False,
|
|
verbose=False,
|
|
filesystem_handler=filesystem_handler,
|
|
config_handler=config_handler
|
|
)
|
|
|
|
# Create temporary output file
|
|
output_file = tempfile.NamedTemporaryFile(mode='w+', delete=False, suffix='.ttw_output', encoding='utf-8')
|
|
output_file_path = Path(output_file.name)
|
|
output_file.close()
|
|
|
|
# Start installation via backend (non-blocking)
|
|
self.process_and_buffer_line("Starting TTW installation...")
|
|
self.flush_output_buffer()
|
|
|
|
self.proc, error_msg = ttw_handler.start_ttw_installation(
|
|
Path(self.mpi_path),
|
|
Path(self.install_dir),
|
|
output_file_path
|
|
)
|
|
|
|
if not self.proc:
|
|
self.installation_finished.emit(False, error_msg or "Failed to start TTW installation")
|
|
return
|
|
|
|
self.process_and_buffer_line("TTW_Linux_Installer process started, monitoring output...")
|
|
self.flush_output_buffer()
|
|
|
|
# Poll output file with batching for UI responsiveness
|
|
last_position = 0
|
|
BATCH_INTERVAL = 0.3 # Emit batches every 300ms
|
|
|
|
while self.proc.poll() is None:
|
|
if self.cancelled:
|
|
break
|
|
|
|
try:
|
|
# Read new content from file
|
|
with open(output_file_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
f.seek(last_position)
|
|
new_lines = f.readlines()
|
|
last_position = f.tell()
|
|
|
|
# Process lines in worker thread (heavy work done here, not UI thread)
|
|
for line in new_lines:
|
|
if self.cancelled:
|
|
break
|
|
self.process_and_buffer_line(line.rstrip())
|
|
|
|
# Emit batch if enough time has passed
|
|
current_time = time.time()
|
|
if current_time - self.last_emit_time >= BATCH_INTERVAL:
|
|
self.flush_output_buffer()
|
|
|
|
except Exception:
|
|
pass
|
|
|
|
# Sleep longer since we're batching
|
|
time.sleep(0.1)
|
|
|
|
# Read any remaining output
|
|
try:
|
|
with open(output_file_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
f.seek(last_position)
|
|
remaining_lines = f.readlines()
|
|
for line in remaining_lines:
|
|
self.process_and_buffer_line(line.rstrip())
|
|
self.flush_output_buffer()
|
|
except Exception:
|
|
pass
|
|
|
|
# Clean up
|
|
try:
|
|
output_file_path.unlink(missing_ok=True)
|
|
except Exception:
|
|
pass
|
|
|
|
ttw_handler.cleanup_ttw_process(self.proc)
|
|
|
|
# Check result
|
|
returncode = self.proc.returncode if self.proc else -1
|
|
if self.cancelled:
|
|
self.installation_finished.emit(False, "Installation cancelled by user")
|
|
elif returncode == 0:
|
|
self.installation_finished.emit(True, "TTW installation completed successfully!")
|
|
else:
|
|
self.installation_finished.emit(False, f"TTW installation failed with exit code {returncode}")
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
traceback.print_exc()
|
|
self.installation_finished.emit(False, f"Installation error: {str(e)}")
|
|
|
|
# Start the installation thread
|
|
self.install_thread = TTWInstallationThread(mpi_path, install_dir)
|
|
# Use QueuedConnection to ensure signals are processed asynchronously and don't block UI
|
|
self.install_thread.output_batch_received.connect(self.on_installation_output_batch, Qt.QueuedConnection)
|
|
self.install_thread.progress_received.connect(self.on_installation_progress, Qt.QueuedConnection)
|
|
self.install_thread.installation_finished.connect(self.on_installation_finished, Qt.QueuedConnection)
|
|
|
|
# Start thread and immediately process events to show initial UI state
|
|
self.install_thread.start()
|
|
QApplication.processEvents() # Process any pending events to update UI immediately
|
|
|
|
def on_installation_output_batch(self, messages):
|
|
"""Handle batched output from TTW_Linux_Installer (already processed in worker thread)"""
|
|
# Lines are already cleaned (ANSI codes stripped, emojis removed) in worker thread
|
|
# CRITICAL: Accumulate all console updates and do ONE widget update per batch
|
|
|
|
if not hasattr(self, '_ttw_seen_lines'):
|
|
self._ttw_seen_lines = set()
|
|
self._ttw_current_phase = None
|
|
self._ttw_last_progress = 0
|
|
self._ttw_last_activity_update = 0
|
|
self.ttw_start_time = time.time()
|
|
|
|
# Accumulate lines to display (do ONE console update at end)
|
|
lines_to_display = []
|
|
html_fragments = []
|
|
show_details_due_to_error = False
|
|
latest_progress = None # Track latest progress to update activity ONCE per batch
|
|
|
|
for cleaned in messages:
|
|
if not cleaned:
|
|
continue
|
|
|
|
lower_cleaned = cleaned.lower()
|
|
|
|
# Extract progress (but don't update UI yet - wait until end of batch)
|
|
try:
|
|
progress_match = re.search(r'\[(\d+)/(\d+)\]', cleaned)
|
|
if progress_match:
|
|
current = int(progress_match.group(1))
|
|
total = int(progress_match.group(2))
|
|
percent = int((current / total) * 100) if total > 0 else 0
|
|
latest_progress = (current, total, percent)
|
|
|
|
if 'loading manifest:' in lower_cleaned:
|
|
manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower_cleaned)
|
|
if manifest_match:
|
|
current = int(manifest_match.group(1))
|
|
total = int(manifest_match.group(2))
|
|
self._ttw_current_phase = "Loading manifest"
|
|
except Exception:
|
|
pass
|
|
|
|
# Determine if we should show this line
|
|
is_error = 'error:' in lower_cleaned and 'succeeded' not in lower_cleaned and '0 failed' not in lower_cleaned
|
|
is_warning = 'warning:' in lower_cleaned
|
|
is_milestone = any(kw in lower_cleaned for kw in ['===', 'complete', 'finished', 'validation', 'configuration valid'])
|
|
is_file_op = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx'])
|
|
|
|
# Filter out meaningless standalone messages (just "OK", etc.)
|
|
is_noise = cleaned.strip().upper() in ['OK', 'OK.', 'OK!', 'DONE', 'DONE.', 'SUCCESS', 'SUCCESS.']
|
|
|
|
should_show = (is_error or is_warning or is_milestone) or (self.show_details_checkbox.isChecked() and not is_file_op and not is_noise)
|
|
|
|
if should_show:
|
|
if is_error or is_warning:
|
|
color = '#f44336' if is_error else '#ff9800'
|
|
prefix = "WARNING: " if is_warning else "ERROR: "
|
|
escaped = (prefix + cleaned).replace('&', '&').replace('<', '<').replace('>', '>')
|
|
html_fragments.append(f'<span style="color: {color};">{escaped}</span>')
|
|
show_details_due_to_error = True
|
|
else:
|
|
lines_to_display.append(cleaned)
|
|
|
|
# Update activity widget ONCE per batch (if progress changed significantly)
|
|
if latest_progress:
|
|
current, total, percent = latest_progress
|
|
current_time = time.time()
|
|
percent_changed = abs(percent - self._ttw_last_progress) >= 1
|
|
time_passed = (current_time - self._ttw_last_activity_update) >= 0.5 # 500ms throttle
|
|
|
|
if percent_changed or time_passed:
|
|
self._update_ttw_activity(current, total, percent)
|
|
self._ttw_last_progress = percent
|
|
self._ttw_last_activity_update = current_time
|
|
|
|
# Now do ONE console update for entire batch
|
|
if html_fragments or lines_to_display:
|
|
try:
|
|
# Update console with all accumulated output in one operation
|
|
if html_fragments:
|
|
combined_html = '<br>'.join(html_fragments)
|
|
self.console.insertHtml(combined_html + '<br>')
|
|
|
|
if lines_to_display:
|
|
combined_text = '\n'.join(lines_to_display)
|
|
self.console.append(combined_text)
|
|
|
|
if show_details_due_to_error and not self.show_details_checkbox.isChecked():
|
|
self.show_details_checkbox.setChecked(True)
|
|
except Exception:
|
|
pass
|
|
|
|
def on_installation_output(self, message):
|
|
"""Handle regular output from TTW_Linux_Installer with comprehensive filtering and smart parsing"""
|
|
# Initialize tracking structures
|
|
if not hasattr(self, '_ttw_seen_lines'):
|
|
self._ttw_seen_lines = set()
|
|
self._ttw_last_extraction_progress = 0
|
|
self._ttw_last_file_operation_time = 0
|
|
self._ttw_file_operation_count = 0
|
|
self._ttw_current_phase = None
|
|
self._ttw_last_progress_line = None
|
|
self._ttw_progress_line_text = None
|
|
|
|
# Filter out internal status messages from user console
|
|
if message.strip().startswith('[Jackify]'):
|
|
# Log internal messages to file but don't show in console
|
|
self._write_to_log_file(message)
|
|
return
|
|
|
|
# Strip ANSI terminal control codes
|
|
cleaned = strip_ansi_control_codes(message).strip()
|
|
|
|
# Strip emojis from output (TTW_Linux_Installer includes emojis)
|
|
# Common emojis: ✅ ❌ ⚠️ 🔍 💾 📁 🚀 🛑
|
|
# Use character-by-character filtering to avoid regex recursion issues
|
|
# This is safer than regex for emoji removal
|
|
filtered_chars = []
|
|
for char in cleaned:
|
|
code = ord(char)
|
|
# Check if character is in emoji ranges - skip emojis
|
|
is_emoji = (
|
|
(0x1F300 <= code <= 0x1F9FF) or # Miscellaneous Symbols and Pictographs
|
|
(0x1F600 <= code <= 0x1F64F) or # Emoticons
|
|
(0x2600 <= code <= 0x26FF) or # Miscellaneous Symbols
|
|
(0x2700 <= code <= 0x27BF) # Dingbats
|
|
)
|
|
if not is_emoji:
|
|
filtered_chars.append(char)
|
|
cleaned = ''.join(filtered_chars).strip()
|
|
|
|
# Filter out empty lines
|
|
if not cleaned:
|
|
return
|
|
|
|
# Initialize start time if not set
|
|
if not hasattr(self, 'ttw_start_time'):
|
|
self.ttw_start_time = time.time()
|
|
|
|
lower_cleaned = cleaned.lower()
|
|
|
|
# === MINIMAL PROCESSING: Match standalone behavior as closely as possible ===
|
|
# When running standalone: output goes directly to terminal, no processing
|
|
# Here: We must process each line, but do it as efficiently as possible
|
|
|
|
# Always log to file (simple, no recursion risk)
|
|
try:
|
|
self._write_to_log_file(cleaned)
|
|
except Exception:
|
|
pass
|
|
|
|
# Extract progress for Activity window (minimal regex, wrapped in try/except)
|
|
try:
|
|
# Try [X/Y] pattern
|
|
progress_match = re.search(r'\[(\d+)/(\d+)\]', cleaned)
|
|
if progress_match:
|
|
current = int(progress_match.group(1))
|
|
total = int(progress_match.group(2))
|
|
percent = int((current / total) * 100) if total > 0 else 0
|
|
phase = self._ttw_current_phase or "Processing"
|
|
self._update_ttw_activity(current, total, percent)
|
|
|
|
# Try "Loading manifest: X/Y"
|
|
if 'loading manifest:' in lower_cleaned:
|
|
manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower_cleaned)
|
|
if manifest_match:
|
|
current = int(manifest_match.group(1))
|
|
total = int(manifest_match.group(2))
|
|
percent = int((current / total) * 100) if total > 0 else 0
|
|
self._ttw_current_phase = "Loading manifest"
|
|
self._update_ttw_activity(current, total, percent)
|
|
except Exception:
|
|
pass # Skip if regex fails
|
|
|
|
# Determine if we should show this line
|
|
# By default: only show errors, warnings, milestones
|
|
# Everything else: only in details mode
|
|
is_error = 'error:' in lower_cleaned and 'succeeded' not in lower_cleaned and '0 failed' not in lower_cleaned
|
|
is_warning = 'warning:' in lower_cleaned
|
|
is_milestone = any(kw in lower_cleaned for kw in ['===', 'complete', 'finished', 'validation', 'configuration valid'])
|
|
is_file_op = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx'])
|
|
|
|
# Filter out meaningless standalone messages (just "OK", etc.)
|
|
is_noise = cleaned.strip().upper() in ['OK', 'OK.', 'OK!', 'DONE', 'DONE.', 'SUCCESS', 'SUCCESS.']
|
|
|
|
should_show = (is_error or is_warning or is_milestone) or (self.show_details_checkbox.isChecked() and not is_file_op and not is_noise)
|
|
|
|
if should_show:
|
|
# Direct console append - no recursion, no complex processing
|
|
try:
|
|
if is_error or is_warning:
|
|
# Color code errors/warnings
|
|
color = '#f44336' if is_error else '#ff9800'
|
|
prefix = "WARNING: " if is_warning else "ERROR: "
|
|
escaped = (prefix + cleaned).replace('&', '&').replace('<', '<').replace('>', '>')
|
|
html = f'<span style="color: {color};">{escaped}</span><br>'
|
|
self.console.insertHtml(html)
|
|
if not self.show_details_checkbox.isChecked():
|
|
self.show_details_checkbox.setChecked(True)
|
|
else:
|
|
self.console.append(cleaned)
|
|
except Exception:
|
|
pass # Don't break on console errors
|
|
|
|
return
|
|
# Simplified: Only extract progress, don't filter file operations (show in details mode)
|
|
# Extract progress from lines like: [44908/58889] or [X/Y]
|
|
progress_match = None
|
|
try:
|
|
progress_match = re.search(r'\[(\d+)/(\d+)\]', cleaned)
|
|
except (RecursionError, re.error):
|
|
pass
|
|
|
|
if progress_match:
|
|
current = int(progress_match.group(1))
|
|
total = int(progress_match.group(2))
|
|
percent = int((current / total) * 100) if total > 0 else 0
|
|
|
|
# Check if this looks like a file operation line (has file extension)
|
|
is_file_operation = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx'])
|
|
|
|
if is_file_operation:
|
|
# File operation - only show in details mode, but still extract progress for Activity window
|
|
self._ttw_file_operation_count += 1
|
|
phase_name = self._ttw_current_phase or "Processing files"
|
|
|
|
# Update Activity Window with phase and counters
|
|
self._update_ttw_activity(current, total, percent)
|
|
|
|
# Only show in details mode
|
|
if self.show_details_checkbox.isChecked():
|
|
elapsed = int(time.time() - self.ttw_start_time)
|
|
elapsed_min = elapsed // 60
|
|
elapsed_sec = elapsed % 60
|
|
progress_text = f"{phase_name}: {current}/{total} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s"
|
|
self._update_progress_line(progress_text)
|
|
return
|
|
|
|
# === COLLAPSE REPETITIVE EXTRACTION PROGRESS ===
|
|
# Pattern: "Extracted 100/27290 files..." - simplified with error handling
|
|
extraction_progress_match = None
|
|
try:
|
|
extraction_progress_match = re.search(r'Extracted\s+(\d+)/(\d+)\s+files', cleaned, re.IGNORECASE)
|
|
except (RecursionError, re.error):
|
|
pass
|
|
|
|
if extraction_progress_match:
|
|
current = int(extraction_progress_match.group(1))
|
|
total = int(extraction_progress_match.group(2))
|
|
percent = int((current / total) * 100) if total > 0 else 0
|
|
|
|
# Update phase with counters (always update Activity window)
|
|
phase_name = "Extracting MPI package"
|
|
self._ttw_current_phase = phase_name
|
|
self._update_ttw_phase(phase_name, current, total, percent)
|
|
|
|
# Only show progress line in details mode
|
|
if self.show_details_checkbox.isChecked():
|
|
elapsed = int(time.time() - self.ttw_start_time)
|
|
elapsed_min = elapsed // 60
|
|
elapsed_sec = elapsed % 60
|
|
progress_text = f"{phase_name}: {current}/{total} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s"
|
|
self._update_progress_line(progress_text)
|
|
|
|
# Update last progress tracking
|
|
self._ttw_last_extraction_progress = current
|
|
return
|
|
|
|
# === IMPORTANT MILESTONES AND SUMMARIES ===
|
|
# Simplified: Use simple string checks instead of regex
|
|
milestone_keywords = ['===', 'complete', 'finished', 'installation summary', 'assets processed',
|
|
'validation complete', 'bsa creation', 'post-commands', 'operation summary',
|
|
'package:', 'variables:', 'locations:', 'assets:', 'loaded', 'successfully parsed']
|
|
is_milestone = any(keyword in lower_cleaned for keyword in milestone_keywords)
|
|
|
|
if is_milestone:
|
|
self._safe_append_text(cleaned)
|
|
return
|
|
|
|
# === PROGRESS PATTERNS ===
|
|
# Pattern 1: "Progress: 50% (1234/5678)" - simplified regex with error handling
|
|
progress_pct_match = None
|
|
try:
|
|
progress_pct_match = re.search(r'(\d+)%\s*\((\d+)/(\d+)\)', cleaned)
|
|
except (RecursionError, re.error):
|
|
pass
|
|
|
|
if progress_pct_match:
|
|
percent = int(progress_pct_match.group(1))
|
|
current = int(progress_pct_match.group(2))
|
|
total = int(progress_pct_match.group(3))
|
|
|
|
if not hasattr(self, 'ttw_total_assets'):
|
|
self.ttw_total_assets = total
|
|
|
|
elapsed = int(time.time() - self.ttw_start_time)
|
|
elapsed_min = elapsed // 60
|
|
elapsed_sec = elapsed % 60
|
|
|
|
self.status_banner.setText(
|
|
f"Installing TTW: {current}/{total} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s"
|
|
)
|
|
|
|
# Update Activity Window with phase and counters
|
|
phase_name = self._ttw_current_phase or "Processing"
|
|
self._update_ttw_activity(current, total, percent)
|
|
|
|
# Only show progress line in details mode
|
|
if self.show_details_checkbox.isChecked():
|
|
progress_text = f"{phase_name}: {current}/{total} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s"
|
|
self._update_progress_line(progress_text)
|
|
return
|
|
|
|
# Pattern 2: "[X/Y]" with context OR "Loading manifest: X/Y" pattern - simplified with error handling
|
|
progress_match = None
|
|
loading_manifest_match = None
|
|
try:
|
|
progress_match = re.search(r'\[(\d+)/(\d+)\]', cleaned)
|
|
loading_manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower_cleaned)
|
|
except (RecursionError, re.error):
|
|
pass
|
|
|
|
if loading_manifest_match:
|
|
# Special handling for "Loading manifest: X/Y" - always show this progress
|
|
current = int(loading_manifest_match.group(1))
|
|
total = int(loading_manifest_match.group(2))
|
|
percent = int((current / total) * 100) if total > 0 else 0
|
|
|
|
# Extract elapsed time if present - simplified with error handling
|
|
elapsed_match = None
|
|
try:
|
|
elapsed_match = re.search(r'elapsed:\s*(\d+)m\s*(\d+)s', lower_cleaned)
|
|
except (RecursionError, re.error):
|
|
pass
|
|
|
|
if elapsed_match:
|
|
elapsed_min = int(elapsed_match.group(1))
|
|
elapsed_sec = int(elapsed_match.group(2))
|
|
else:
|
|
elapsed = int(time.time() - self.ttw_start_time)
|
|
elapsed_min = elapsed // 60
|
|
elapsed_sec = elapsed % 60
|
|
|
|
phase_name = "Loading manifest"
|
|
self._ttw_current_phase = phase_name
|
|
|
|
# Remove duplicate percentage - status banner already shows it
|
|
self.status_banner.setText(
|
|
f"Loading manifest: {current:,}/{total:,} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s"
|
|
)
|
|
|
|
# Update single progress line (but show periodic updates to indicate activity)
|
|
progress_text = f"{phase_name}: {current}/{total} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s"
|
|
|
|
# Show periodic updates (every 2% or every 5 seconds) to indicate process is alive
|
|
# More frequent updates to prevent appearance of hanging
|
|
if not hasattr(self, '_ttw_last_manifest_percent'):
|
|
self._ttw_last_manifest_percent = 0
|
|
self._ttw_last_manifest_time = time.time()
|
|
|
|
percent_diff = percent - self._ttw_last_manifest_percent
|
|
time_diff = time.time() - self._ttw_last_manifest_time
|
|
|
|
# Update progress line, but also show new line if significant progress or time elapsed
|
|
# More frequent updates (every 2% or 5 seconds) to show activity
|
|
if percent_diff >= 2 or time_diff >= 5:
|
|
# Significant progress or time elapsed - show as new line to indicate activity
|
|
self._safe_append_text(progress_text)
|
|
self._ttw_progress_line_text = progress_text
|
|
self._ttw_last_manifest_percent = percent
|
|
self._ttw_last_manifest_time = time.time()
|
|
else:
|
|
# Small progress - just update the line
|
|
self._update_progress_line(progress_text)
|
|
|
|
# Update Activity Window with phase and counters
|
|
self._update_ttw_activity(current, total, percent)
|
|
|
|
# Process events to keep UI responsive during long operations
|
|
QApplication.processEvents()
|
|
return
|
|
|
|
if progress_match:
|
|
current = int(progress_match.group(1))
|
|
total = int(progress_match.group(2))
|
|
|
|
# Check if this is a meaningful progress line (not a file operation we already handled)
|
|
if any(keyword in lower_cleaned for keyword in ['writing', 'creating', 'processing', 'installing', 'extracting', 'loading']):
|
|
if not hasattr(self, 'ttw_total_assets'):
|
|
self.ttw_total_assets = total
|
|
|
|
# Detect specific phases from context (simple string checks)
|
|
phase_name = self._ttw_current_phase
|
|
if 'bsa' in lower_cleaned or 'writing' in lower_cleaned:
|
|
phase_name = "Writing BSA archives"
|
|
self._ttw_current_phase = phase_name
|
|
elif 'loading' in lower_cleaned:
|
|
phase_name = "Loading manifest"
|
|
self._ttw_current_phase = phase_name
|
|
elif not phase_name:
|
|
phase_name = "Processing"
|
|
|
|
percent = int((current / total) * 100) if total > 0 else 0
|
|
elapsed = int(time.time() - self.ttw_start_time)
|
|
elapsed_min = elapsed // 60
|
|
elapsed_sec = elapsed % 60
|
|
|
|
self.status_banner.setText(
|
|
f"Installing TTW: {current}/{total} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s"
|
|
)
|
|
|
|
# Update Activity Window with phase and counters (always)
|
|
self._update_ttw_activity(current, total, percent)
|
|
|
|
# Only show progress line in details mode
|
|
if self.show_details_checkbox.isChecked():
|
|
progress_text = f"{phase_name}: {current}/{total} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s"
|
|
self._update_progress_line(progress_text)
|
|
return
|
|
|
|
# === PHASE DETECTION ===
|
|
phase_keywords = {
|
|
'extracting': 'Extracting MPI package',
|
|
'downloading': 'Downloading files',
|
|
'loading manifest': 'Loading manifest',
|
|
'parsing assets': 'Parsing assets',
|
|
'validation': 'Running validation',
|
|
'installing': 'Installing TTW',
|
|
'writing bsa': 'Writing BSA archives',
|
|
'post-installation': 'Running post-installation commands',
|
|
'cleaning up': 'Cleaning up'
|
|
}
|
|
|
|
for keyword, phase_name in phase_keywords.items():
|
|
if keyword in lower_cleaned:
|
|
if self._ttw_current_phase != phase_name:
|
|
# Start new phase - just update Activity window and show phase message
|
|
self._ttw_current_phase = phase_name
|
|
self._update_ttw_phase(phase_name) # Start phase without counters initially
|
|
if self.show_details_checkbox.isChecked():
|
|
self._safe_append_text(f"{phase_name}...")
|
|
self._ttw_progress_line_text = None # Reset progress line
|
|
return
|
|
|
|
# === CONFIGURATION AND VALIDATION MESSAGES ===
|
|
# Simplified: Use simple string checks
|
|
config_keywords = ['fallout 3:', 'fallout nv:', 'output:', 'mpi package:', 'configuration valid',
|
|
'validating configuration', 'verifying', 'file correctly absent', 'disk space check']
|
|
is_config = any(keyword in lower_cleaned for keyword in config_keywords)
|
|
|
|
if is_config:
|
|
self._safe_append_text(cleaned)
|
|
return
|
|
|
|
# === EXECUTION COMMANDS (filter most, show important ones) ===
|
|
if 'executing:' in lower_cleaned or 'cmd.exe' in lower_cleaned:
|
|
# Only show rename operations and failures, not every delete/rename
|
|
if 'renamed:' in lower_cleaned or 'ren:' in lower_cleaned:
|
|
if self.show_details_checkbox.isChecked():
|
|
self._safe_append_text(cleaned)
|
|
return
|
|
|
|
# === PATCH/LZ4 DECOMPRESSION MESSAGES ===
|
|
# Show these to indicate activity during manifest loading
|
|
if 'patch' in lower_cleaned and ('lz4' in lower_cleaned or 'decompressing' in lower_cleaned):
|
|
# Show patch decompression messages (but not errors - those are handled above)
|
|
if 'error' not in lower_cleaned and 'failed' not in lower_cleaned:
|
|
# Just a status message - show it briefly or in details mode
|
|
if self.show_details_checkbox.isChecked():
|
|
self._safe_append_text(cleaned)
|
|
# Don't return - let it fall through to default handling
|
|
else:
|
|
# Error message - already handled by error detection above
|
|
return
|
|
|
|
# === DEFAULT: Only show in details mode ===
|
|
if self.show_details_checkbox.isChecked():
|
|
self._safe_append_text(cleaned)
|
|
|
|
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 _update_progress_line(self, text):
|
|
"""Update progress - just append, don't try to replace (simpler and safer)"""
|
|
# Simplified: Just append progress lines instead of trying to replace
|
|
# This avoids Qt cursor manipulation issues that cause SystemError
|
|
# Only show in details mode to avoid spam
|
|
if self.show_details_checkbox.isChecked():
|
|
self._safe_append_text(text)
|
|
# Always track for Activity window updates (handled separately)
|
|
self._ttw_progress_line_text = text
|
|
|
|
def _update_ttw_elapsed_time(self):
|
|
"""Update status banner with elapsed time"""
|
|
if hasattr(self, 'ttw_start_time'):
|
|
elapsed = int(time.time() - self.ttw_start_time)
|
|
minutes = elapsed // 60
|
|
seconds = elapsed % 60
|
|
self.status_banner.setText(f"Processing Tale of Two Wastelands installation... Elapsed: {minutes}m {seconds}s")
|
|
|
|
def on_installation_finished(self, success, message):
|
|
"""Handle installation completion"""
|
|
debug_print(f"DEBUG: on_installation_finished called with success={success}, message={message}")
|
|
|
|
# Stop elapsed timer
|
|
if hasattr(self, 'ttw_elapsed_timer'):
|
|
self.ttw_elapsed_timer.stop()
|
|
|
|
# Update status banner
|
|
if success:
|
|
elapsed = int(time.time() - self.ttw_start_time) if hasattr(self, 'ttw_start_time') else 0
|
|
minutes = elapsed // 60
|
|
seconds = elapsed % 60
|
|
self.status_banner.setText(f"Installation completed successfully! Total time: {minutes}m {seconds}s")
|
|
self.status_banner.setStyleSheet(f"""
|
|
background-color: #1a4d1a;
|
|
color: #4CAF50;
|
|
padding: 8px;
|
|
border-radius: 4px;
|
|
font-weight: bold;
|
|
font-size: 13px;
|
|
""")
|
|
self._safe_append_text(f"\nSuccess: {message}")
|
|
self.process_finished(0, QProcess.NormalExit)
|
|
else:
|
|
self.status_banner.setText(f"Installation failed: {message}")
|
|
self.status_banner.setStyleSheet(f"""
|
|
background-color: #4d1a1a;
|
|
color: #f44336;
|
|
padding: 8px;
|
|
border-radius: 4px;
|
|
font-weight: bold;
|
|
font-size: 13px;
|
|
""")
|
|
self._safe_append_text(f"\nError: {message}")
|
|
self.process_finished(1, QProcess.CrashExit)
|
|
|
|
def process_finished(self, exit_code, exit_status):
|
|
debug_print(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}")
|
|
# Reset button states
|
|
self.start_btn.setEnabled(True)
|
|
self.cancel_btn.setVisible(True)
|
|
self.cancel_install_btn.setVisible(False)
|
|
debug_print("DEBUG: Button states reset in process_finished")
|
|
|
|
|
|
if exit_code == 0:
|
|
# TTW installation complete
|
|
self._safe_append_text("\nTTW installation completed successfully!")
|
|
self._safe_append_text("The merged TTW files have been created in the output directory.")
|
|
|
|
# Check if we're in modlist integration mode
|
|
if self._integration_mode:
|
|
self._safe_append_text("\nIntegrating TTW into modlist...")
|
|
self._perform_modlist_integration()
|
|
else:
|
|
# Standard mode - ask user if they want to create a mod archive for MO2
|
|
reply = MessageService.question(
|
|
self, "TTW Installation Complete!",
|
|
"Tale of Two Wastelands installation completed successfully!\n\n"
|
|
f"Output location: {self.install_dir_edit.text()}\n\n"
|
|
"Would you like to create a zipped mod archive for MO2?\n"
|
|
"This will package the TTW files for easy installation into Mod Organizer 2.",
|
|
critical=False
|
|
)
|
|
|
|
if reply == QMessageBox.Yes:
|
|
self._create_ttw_mod_archive()
|
|
else:
|
|
MessageService.information(
|
|
self, "Installation Complete",
|
|
"TTW installation complete!\n\n"
|
|
"You can manually use the TTW files from the output directory.",
|
|
safety_level="medium"
|
|
)
|
|
else:
|
|
# Check for user cancellation first
|
|
last_output = self.console.toPlainText()
|
|
if "cancelled by user" in last_output.lower():
|
|
MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low")
|
|
else:
|
|
MessageService.critical(self, "Install Failed", "The modlist install failed. Please check the console output for details.")
|
|
self._safe_append_text(f"\nInstall failed (exit code {exit_code}).")
|
|
self.console.moveCursor(QTextCursor.End)
|
|
|
|
def _setup_scroll_tracking(self):
|
|
"""Set up scroll tracking for professional auto-scroll behavior"""
|
|
scrollbar = self.console.verticalScrollBar()
|
|
scrollbar.sliderPressed.connect(self._on_scrollbar_pressed)
|
|
scrollbar.sliderReleased.connect(self._on_scrollbar_released)
|
|
scrollbar.valueChanged.connect(self._on_scrollbar_value_changed)
|
|
|
|
def _on_scrollbar_pressed(self):
|
|
"""User started manually scrolling"""
|
|
self._user_manually_scrolled = True
|
|
|
|
def _on_scrollbar_released(self):
|
|
"""User finished manually scrolling"""
|
|
self._user_manually_scrolled = False
|
|
|
|
def _on_scrollbar_value_changed(self):
|
|
"""Track if user is at bottom of scroll area"""
|
|
scrollbar = self.console.verticalScrollBar()
|
|
# Use tolerance to account for rounding and rapid updates
|
|
self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1
|
|
|
|
# If user manually scrolls to bottom, reset manual scroll flag
|
|
if self._was_at_bottom and self._user_manually_scrolled:
|
|
# Small delay to allow user to scroll away if they want
|
|
from PySide6.QtCore import QTimer
|
|
QTimer.singleShot(100, self._reset_manual_scroll_if_at_bottom)
|
|
|
|
def _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 _on_show_details_toggled(self, checked: bool):
|
|
from PySide6.QtCore import Qt as _Qt
|
|
self._toggle_console_visibility(_Qt.Checked if checked else _Qt.Unchecked)
|
|
|
|
def _toggle_console_visibility(self, state):
|
|
"""Toggle console visibility and resize main window"""
|
|
is_checked = (state == Qt.Checked)
|
|
main_window = self.window()
|
|
|
|
if not main_window:
|
|
return
|
|
|
|
# Check if we're on Steam Deck
|
|
is_steamdeck = False
|
|
if self.system_info and getattr(self.system_info, 'is_steamdeck', False):
|
|
is_steamdeck = True
|
|
elif not self.system_info and main_window and hasattr(main_window, 'system_info'):
|
|
is_steamdeck = getattr(main_window.system_info, 'is_steamdeck', False)
|
|
|
|
# Console height when expanded
|
|
console_height = 300
|
|
|
|
if is_checked:
|
|
# Show console
|
|
self.console.setVisible(True)
|
|
self.console.show()
|
|
self.console.setMinimumHeight(200)
|
|
self.console.setMaximumHeight(16777215)
|
|
try:
|
|
self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self.main_overall_vbox.setStretchFactor(self.console, 1)
|
|
except Exception:
|
|
pass
|
|
|
|
# On Steam Deck, skip window resizing - keep default Steam Deck window size
|
|
if is_steamdeck:
|
|
debug_print("DEBUG: Steam Deck detected, skipping window resize in _toggle_console_visibility")
|
|
return
|
|
|
|
# Restore main window to normal size (clear any compact constraints)
|
|
# On Steam Deck, keep fullscreen; on other systems, set normal window state
|
|
if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck):
|
|
main_window.showNormal()
|
|
main_window.setMaximumHeight(16777215)
|
|
main_window.setMinimumHeight(0)
|
|
# Restore original minimum size so the window can expand normally
|
|
try:
|
|
if self._saved_min_size is not None:
|
|
main_window.setMinimumSize(self._saved_min_size)
|
|
except Exception:
|
|
pass
|
|
# Prefer exact original geometry if known
|
|
if self._saved_geometry is not None:
|
|
main_window.setGeometry(self._saved_geometry)
|
|
else:
|
|
expanded_min = 900
|
|
current_size = main_window.size()
|
|
target_height = max(expanded_min, 900)
|
|
main_window.setMinimumHeight(expanded_min)
|
|
main_window.resize(current_size.width(), target_height)
|
|
try:
|
|
# Encourage layouts to recompute sizes
|
|
self.main_overall_vbox.invalidate()
|
|
self.updateGeometry()
|
|
except Exception:
|
|
pass
|
|
# Notify parent to expand
|
|
try:
|
|
self.resize_request.emit('expand')
|
|
except Exception:
|
|
pass
|
|
else:
|
|
# Hide console fully (removes it from layout sizing)
|
|
self.console.setVisible(False)
|
|
self.console.hide()
|
|
self.console.setMinimumHeight(0)
|
|
self.console.setMaximumHeight(0)
|
|
try:
|
|
# Make the hidden console contribute no expand pressure
|
|
self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self.main_overall_vbox.setStretchFactor(self.console, 0)
|
|
except Exception:
|
|
pass
|
|
|
|
# On Steam Deck, skip window resizing to keep maximized state
|
|
if is_steamdeck:
|
|
debug_print("DEBUG: Steam Deck detected, skipping window resize in collapse branch")
|
|
return
|
|
|
|
# Use fixed compact height for consistency across all workflow screens
|
|
compact_height = 620
|
|
# On Steam Deck, keep fullscreen; on other systems, set normal window state
|
|
if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck):
|
|
main_window.showNormal()
|
|
# Set minimum height but no maximum to allow user resizing
|
|
try:
|
|
from PySide6.QtCore import QSize
|
|
set_responsive_minimum(main_window, min_width=960, min_height=compact_height)
|
|
main_window.setMaximumSize(QSize(16777215, 16777215)) # No maximum
|
|
except Exception:
|
|
pass
|
|
|
|
# Resize to compact height to avoid leftover space
|
|
current_size = main_window.size()
|
|
main_window.resize(current_size.width(), compact_height)
|
|
# Notify parent to collapse
|
|
try:
|
|
self.resize_request.emit('collapse')
|
|
except Exception:
|
|
pass
|
|
|
|
def _update_ttw_activity(self, current, total, percent):
|
|
"""Update Activity window with TTW installation progress"""
|
|
try:
|
|
# Determine current phase based on progress
|
|
if not hasattr(self, '_ttw_current_phase'):
|
|
self._ttw_current_phase = None
|
|
|
|
# Use current phase name or default
|
|
phase_name = self._ttw_current_phase or "Processing"
|
|
|
|
# Update or add activity item showing current progress with phase name and counters
|
|
# Don't include percentage in label - progress bar shows it
|
|
label = f"{phase_name}: {current:,}/{total:,}"
|
|
self.file_progress_list.update_or_add_item(
|
|
item_id="ttw_progress",
|
|
label=label,
|
|
progress=percent
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def _update_ttw_phase(self, phase_name, current=None, total=None, percent=0):
|
|
"""Update Activity window with current TTW installation phase and optional progress"""
|
|
try:
|
|
self._ttw_current_phase = phase_name
|
|
|
|
# Build label with phase name and counters if provided
|
|
# Don't include percentage in label - progress bar shows it
|
|
if current is not None and total is not None:
|
|
label = f"{phase_name}: {current:,}/{total:,}"
|
|
else:
|
|
label = phase_name
|
|
|
|
# Update or add activity item
|
|
self.file_progress_list.update_or_add_item(
|
|
item_id="ttw_phase",
|
|
label=label,
|
|
progress=percent
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def _safe_append_text(self, text, color=None):
|
|
"""Append text with professional auto-scroll behavior
|
|
|
|
Args:
|
|
text: Text to append
|
|
color: Optional HTML color code (e.g., '#f44336' for red) to format the text
|
|
"""
|
|
# Write all messages to log file (including internal messages)
|
|
self._write_to_log_file(text)
|
|
|
|
# Filter out internal status messages from user console display
|
|
if text.strip().startswith('[Jackify]'):
|
|
# Internal messages are logged but not shown in user console
|
|
return
|
|
|
|
scrollbar = self.console.verticalScrollBar()
|
|
# Check if user was at bottom BEFORE adding text
|
|
was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance
|
|
|
|
# Format text with color if provided
|
|
if color:
|
|
# Escape HTML special characters
|
|
escaped_text = text.replace('&', '&').replace('<', '<').replace('>', '>')
|
|
formatted_text = f'<span style="color: {color};">{escaped_text}</span>'
|
|
# Use insertHtml for colored text (QTextEdit supports HTML in append when using RichText)
|
|
cursor = self.console.textCursor()
|
|
cursor.movePosition(QTextCursor.End)
|
|
self.console.setTextCursor(cursor)
|
|
self.console.insertHtml(formatted_text + '<br>')
|
|
else:
|
|
# Add plain 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"""
|
|
debug_print("DEBUG: restart_steam_and_configure called - using direct backend service")
|
|
progress = QProgressDialog("Restarting Steam...", None, 0, 0, self)
|
|
progress.setWindowTitle("Restarting Steam")
|
|
progress.setWindowModality(Qt.WindowModal)
|
|
progress.setMinimumDuration(0)
|
|
progress.setValue(0)
|
|
progress.show()
|
|
|
|
def do_restart():
|
|
debug_print("DEBUG: do_restart thread started - using direct backend service")
|
|
try:
|
|
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
|
|
|
|
# Use backend service directly instead of CLI subprocess
|
|
shortcut_handler = ShortcutHandler(steamdeck=False) # TODO: Use proper system info
|
|
|
|
debug_print("DEBUG: About to call secure_steam_restart()")
|
|
success = shortcut_handler.secure_steam_restart()
|
|
debug_print(f"DEBUG: secure_steam_restart() returned: {success}")
|
|
|
|
out = "Steam restart completed successfully." if success else "Steam restart failed."
|
|
|
|
except Exception as e:
|
|
debug_print(f"DEBUG: Exception in do_restart: {e}")
|
|
success = False
|
|
out = str(e)
|
|
|
|
self.steam_restart_finished.emit(success, out)
|
|
|
|
threading.Thread(target=do_restart, daemon=True).start()
|
|
self._steam_restart_progress = progress # Store to close later
|
|
|
|
def _on_steam_restart_finished(self, success, out):
|
|
debug_print("DEBUG: _on_steam_restart_finished called")
|
|
# Safely cleanup progress dialog on main thread
|
|
if hasattr(self, '_steam_restart_progress') and self._steam_restart_progress:
|
|
try:
|
|
self._steam_restart_progress.close()
|
|
self._steam_restart_progress.deleteLater() # Use deleteLater() for safer cleanup
|
|
except Exception as e:
|
|
debug_print(f"DEBUG: Error closing progress dialog: {e}")
|
|
finally:
|
|
self._steam_restart_progress = None
|
|
|
|
# Controls are managed by the proper control management system
|
|
if success:
|
|
self._safe_append_text("Steam restarted successfully.")
|
|
|
|
# Save context for later use in configuration
|
|
self._manual_steps_retry_count = 0
|
|
self._current_modlist_name = "TTW Installation" # Fixed name for TTW
|
|
self._current_resolution = None # TTW doesn't need resolution changes
|
|
|
|
# Use automated prefix creation instead of manual steps
|
|
debug_print("DEBUG: Starting automated prefix creation workflow")
|
|
self._safe_append_text("Starting automated prefix creation workflow...")
|
|
self.start_automated_prefix_workflow()
|
|
else:
|
|
self._safe_append_text("Failed to restart Steam.\n" + out)
|
|
MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.")
|
|
|
|
def start_automated_prefix_workflow(self):
|
|
# Ensure _current_resolution is always set before starting workflow
|
|
if not hasattr(self, '_current_resolution') or self._current_resolution is None:
|
|
resolution = None # TTW doesn't need resolution changes
|
|
# Extract resolution properly (e.g., "1280x800" from "1280x800 (Steam Deck)")
|
|
if resolution and resolution != "Leave unchanged":
|
|
if " (" in resolution:
|
|
self._current_resolution = resolution.split(" (")[0]
|
|
else:
|
|
self._current_resolution = resolution
|
|
else:
|
|
self._current_resolution = None
|
|
"""Start the automated prefix creation workflow"""
|
|
try:
|
|
# Disable controls during installation
|
|
self._disable_controls_during_operation()
|
|
modlist_name = "TTW Installation"
|
|
install_dir = self.install_dir_edit.text().strip()
|
|
final_exe_path = os.path.join(install_dir, "ModOrganizer.exe")
|
|
|
|
if not os.path.exists(final_exe_path):
|
|
# Check if this is Somnium specifically (uses files/ subdirectory)
|
|
modlist_name_lower = modlist_name.lower()
|
|
if "somnium" in modlist_name_lower:
|
|
somnium_exe_path = os.path.join(install_dir, "files", "ModOrganizer.exe")
|
|
if os.path.exists(somnium_exe_path):
|
|
final_exe_path = somnium_exe_path
|
|
self._safe_append_text(f"Detected Somnium modlist - will proceed with automated setup")
|
|
# Show Somnium guidance popup after automated workflow completes
|
|
self._show_somnium_guidance = True
|
|
self._somnium_install_dir = install_dir
|
|
else:
|
|
self._safe_append_text(f"ERROR: Somnium ModOrganizer.exe not found at {somnium_exe_path}")
|
|
MessageService.critical(self, "Somnium ModOrganizer.exe Not Found",
|
|
f"Expected Somnium ModOrganizer.exe not found at:\n{somnium_exe_path}\n\nCannot proceed with automated setup.")
|
|
return
|
|
else:
|
|
self._safe_append_text(f"ERROR: ModOrganizer.exe not found at {final_exe_path}")
|
|
MessageService.critical(self, "ModOrganizer.exe Not Found",
|
|
f"ModOrganizer.exe not found at:\n{final_exe_path}\n\nCannot proceed with automated setup.")
|
|
return
|
|
|
|
# Run automated prefix creation in separate thread
|
|
from PySide6.QtCore import QThread, Signal
|
|
|
|
class AutomatedPrefixThread(QThread):
|
|
finished = Signal(bool, str, str, str) # success, prefix_path, appid (as string), last_timestamp
|
|
progress = Signal(str) # progress messages
|
|
error = Signal(str) # error messages
|
|
show_progress_dialog = Signal(str) # show progress dialog with message
|
|
hide_progress_dialog = Signal() # hide progress dialog
|
|
conflict_detected = Signal(list) # conflicts list
|
|
|
|
def __init__(self, modlist_name, install_dir, final_exe_path):
|
|
super().__init__()
|
|
self.modlist_name = modlist_name
|
|
self.install_dir = install_dir
|
|
self.final_exe_path = final_exe_path
|
|
|
|
def run(self):
|
|
try:
|
|
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
|
|
|
def progress_callback(message):
|
|
self.progress.emit(message)
|
|
# Show progress dialog during Steam restart
|
|
if "Steam restarted successfully" in message:
|
|
self.hide_progress_dialog.emit()
|
|
elif "Restarting Steam..." in message:
|
|
self.show_progress_dialog.emit("Restarting Steam...")
|
|
|
|
prefix_service = AutomatedPrefixService()
|
|
# Determine Steam Deck once and pass through the workflow
|
|
try:
|
|
import os
|
|
_is_steamdeck = False
|
|
if os.path.exists('/etc/os-release'):
|
|
with open('/etc/os-release') as f:
|
|
if 'steamdeck' in f.read().lower():
|
|
_is_steamdeck = True
|
|
except Exception:
|
|
_is_steamdeck = False
|
|
result = prefix_service.run_working_workflow(
|
|
self.modlist_name, self.install_dir, self.final_exe_path, progress_callback, steamdeck=_is_steamdeck
|
|
)
|
|
|
|
# Handle the result - check for conflicts
|
|
if isinstance(result, tuple) and len(result) == 4:
|
|
if result[0] == "CONFLICT":
|
|
# Conflict detected - emit signal to main GUI
|
|
conflicts = result[1]
|
|
self.hide_progress_dialog.emit()
|
|
self.conflict_detected.emit(conflicts)
|
|
return
|
|
else:
|
|
# Normal result with timestamp
|
|
success, prefix_path, new_appid, last_timestamp = result
|
|
elif isinstance(result, tuple) and len(result) == 3:
|
|
# Fallback for old format (backward compatibility)
|
|
if result[0] == "CONFLICT":
|
|
# Conflict detected - emit signal to main GUI
|
|
conflicts = result[1]
|
|
self.hide_progress_dialog.emit()
|
|
self.conflict_detected.emit(conflicts)
|
|
return
|
|
else:
|
|
# Normal result (old format)
|
|
success, prefix_path, new_appid = result
|
|
last_timestamp = None
|
|
else:
|
|
# Handle non-tuple result
|
|
success = result
|
|
prefix_path = ""
|
|
new_appid = "0"
|
|
last_timestamp = None
|
|
|
|
# Ensure progress dialog is hidden when workflow completes
|
|
self.hide_progress_dialog.emit()
|
|
self.finished.emit(success, prefix_path or "", str(new_appid) if new_appid else "0", last_timestamp)
|
|
|
|
except Exception as e:
|
|
# Ensure progress dialog is hidden on error
|
|
self.hide_progress_dialog.emit()
|
|
self.error.emit(str(e))
|
|
|
|
# Create and start thread
|
|
self.prefix_thread = AutomatedPrefixThread(modlist_name, install_dir, final_exe_path)
|
|
self.prefix_thread.finished.connect(self.on_automated_prefix_finished)
|
|
self.prefix_thread.error.connect(self.on_automated_prefix_error)
|
|
self.prefix_thread.progress.connect(self.on_automated_prefix_progress)
|
|
self.prefix_thread.show_progress_dialog.connect(self.show_steam_restart_progress)
|
|
self.prefix_thread.hide_progress_dialog.connect(self.hide_steam_restart_progress)
|
|
self.prefix_thread.conflict_detected.connect(self.show_shortcut_conflict_dialog)
|
|
self.prefix_thread.start()
|
|
|
|
except Exception as e:
|
|
debug_print(f"DEBUG: Exception in start_automated_prefix_workflow: {e}")
|
|
import traceback
|
|
debug_print(f"DEBUG: Traceback: {traceback.format_exc()}")
|
|
self._safe_append_text(f"ERROR: Failed to start automated workflow: {e}")
|
|
# Re-enable controls on exception
|
|
self._enable_controls_after_operation()
|
|
|
|
def on_automated_prefix_finished(self, success, prefix_path, new_appid_str, last_timestamp=None):
|
|
"""Handle completion of automated prefix creation"""
|
|
try:
|
|
if success:
|
|
debug_print(f"SUCCESS: Automated prefix creation completed!")
|
|
debug_print(f"Prefix created at: {prefix_path}")
|
|
if new_appid_str and new_appid_str != "0":
|
|
debug_print(f"AppID: {new_appid_str}")
|
|
|
|
# Convert string AppID back to integer for configuration
|
|
new_appid = int(new_appid_str) if new_appid_str and new_appid_str != "0" else None
|
|
|
|
# Continue with configuration using the new AppID and timestamp
|
|
modlist_name = "TTW Installation"
|
|
install_dir = self.install_dir_edit.text().strip()
|
|
self.continue_configuration_after_automated_prefix(new_appid, modlist_name, install_dir, last_timestamp)
|
|
else:
|
|
self._safe_append_text(f"ERROR: Automated prefix creation failed")
|
|
self._safe_append_text("Please check the logs for details")
|
|
MessageService.critical(self, "Automated Setup Failed",
|
|
"Automated prefix creation failed. Please check the console output for details.")
|
|
# Re-enable controls on failure
|
|
self._enable_controls_after_operation()
|
|
finally:
|
|
# Always ensure controls are re-enabled when workflow truly completes
|
|
pass
|
|
|
|
def on_automated_prefix_error(self, error_msg):
|
|
"""Handle error in automated prefix creation"""
|
|
self._safe_append_text(f"ERROR: Error during automated prefix creation: {error_msg}")
|
|
MessageService.critical(self, "Automated Setup Error",
|
|
f"Error during automated prefix creation: {error_msg}")
|
|
# Re-enable controls on error
|
|
self._enable_controls_after_operation()
|
|
|
|
def on_automated_prefix_progress(self, progress_msg):
|
|
"""Handle progress updates from automated prefix creation"""
|
|
self._safe_append_text(progress_msg)
|
|
|
|
def on_configuration_progress(self, progress_msg):
|
|
"""Handle progress updates from modlist configuration"""
|
|
self._safe_append_text(progress_msg)
|
|
|
|
def show_steam_restart_progress(self, message):
|
|
"""Show Steam restart progress dialog"""
|
|
from PySide6.QtWidgets import QProgressDialog
|
|
from PySide6.QtCore import Qt
|
|
|
|
self.steam_restart_progress = QProgressDialog(message, None, 0, 0, self)
|
|
self.steam_restart_progress.setWindowTitle("Restarting Steam")
|
|
self.steam_restart_progress.setWindowModality(Qt.WindowModal)
|
|
self.steam_restart_progress.setMinimumDuration(0)
|
|
self.steam_restart_progress.setValue(0)
|
|
self.steam_restart_progress.show()
|
|
|
|
def hide_steam_restart_progress(self):
|
|
"""Hide Steam restart progress dialog"""
|
|
if hasattr(self, 'steam_restart_progress') and self.steam_restart_progress:
|
|
try:
|
|
self.steam_restart_progress.close()
|
|
self.steam_restart_progress.deleteLater()
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
self.steam_restart_progress = None
|
|
# Controls are managed by the proper control management system
|
|
|
|
def on_configuration_complete(self, success, message, modlist_name):
|
|
"""Handle configuration completion on main thread"""
|
|
try:
|
|
# Re-enable controls now that installation/configuration is complete
|
|
self._enable_controls_after_operation()
|
|
|
|
if success:
|
|
# Check if we need to show Somnium guidance
|
|
if self._show_somnium_guidance:
|
|
self._show_somnium_post_install_guidance()
|
|
|
|
# Show celebration SuccessDialog after the entire workflow
|
|
from ..dialogs import SuccessDialog
|
|
import time
|
|
if not hasattr(self, '_install_workflow_start_time'):
|
|
self._install_workflow_start_time = time.time()
|
|
time_taken = int(time.time() - self._install_workflow_start_time)
|
|
mins, secs = divmod(time_taken, 60)
|
|
time_str = f"{mins} minutes, {secs} seconds" if mins else f"{secs} seconds"
|
|
display_names = {
|
|
'skyrim': 'Skyrim',
|
|
'fallout4': 'Fallout 4',
|
|
'falloutnv': 'Fallout New Vegas',
|
|
'oblivion': 'Oblivion',
|
|
'starfield': 'Starfield',
|
|
'oblivion_remastered': 'Oblivion Remastered',
|
|
'enderal': 'Enderal'
|
|
}
|
|
game_name = display_names.get(self._current_game_type, self._current_game_name)
|
|
success_dialog = SuccessDialog(
|
|
modlist_name=modlist_name,
|
|
workflow_type="install",
|
|
time_taken=time_str,
|
|
game_name=game_name,
|
|
parent=self
|
|
)
|
|
success_dialog.show()
|
|
elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3:
|
|
# Max retries reached - show failure message
|
|
MessageService.critical(self, "Manual Steps Failed",
|
|
"Manual steps validation failed after multiple attempts.")
|
|
else:
|
|
# Configuration failed for other reasons
|
|
MessageService.critical(self, "Configuration Failed",
|
|
"Post-install configuration failed. Please check the console output.")
|
|
except Exception as e:
|
|
# Ensure controls are re-enabled even on unexpected errors
|
|
self._enable_controls_after_operation()
|
|
raise
|
|
# Clean up thread
|
|
if hasattr(self, 'config_thread') and self.config_thread is not None:
|
|
# Disconnect all signals to prevent "Internal C++ object already deleted" errors
|
|
try:
|
|
self.config_thread.progress_update.disconnect()
|
|
self.config_thread.configuration_complete.disconnect()
|
|
self.config_thread.error_occurred.disconnect()
|
|
except:
|
|
pass # Ignore errors if already disconnected
|
|
if self.config_thread.isRunning():
|
|
self.config_thread.quit()
|
|
self.config_thread.wait(5000) # Wait up to 5 seconds
|
|
self.config_thread.deleteLater()
|
|
self.config_thread = None
|
|
|
|
def on_configuration_error(self, error_message):
|
|
"""Handle configuration error on main thread"""
|
|
self._safe_append_text(f"Configuration failed with error: {error_message}")
|
|
MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}")
|
|
|
|
# Re-enable all controls on error
|
|
self._enable_controls_after_operation()
|
|
|
|
# Clean up thread
|
|
if hasattr(self, 'config_thread') and self.config_thread is not None:
|
|
# Disconnect all signals to prevent "Internal C++ object already deleted" errors
|
|
try:
|
|
self.config_thread.progress_update.disconnect()
|
|
self.config_thread.configuration_complete.disconnect()
|
|
self.config_thread.error_occurred.disconnect()
|
|
except:
|
|
pass # Ignore errors if already disconnected
|
|
if self.config_thread.isRunning():
|
|
self.config_thread.quit()
|
|
self.config_thread.wait(5000) # Wait up to 5 seconds
|
|
self.config_thread.deleteLater()
|
|
self.config_thread = None
|
|
|
|
def show_manual_steps_dialog(self, extra_warning=""):
|
|
modlist_name = "TTW Installation"
|
|
msg = (
|
|
f"<b>Manual Proton Setup Required for <span style='color:#3fd0ea'>{modlist_name}</span></b><br>"
|
|
"After Steam restarts, complete the following steps in Steam:<br>"
|
|
f"1. Locate the '<b>{modlist_name}</b>' entry in your Steam Library<br>"
|
|
"2. Right-click and select 'Properties'<br>"
|
|
"3. Switch to the 'Compatibility' tab<br>"
|
|
"4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'<br>"
|
|
"5. Select 'Proton - Experimental' from the dropdown menu<br>"
|
|
"6. Close the Properties window<br>"
|
|
f"7. Launch '<b>{modlist_name}</b>' from your Steam Library<br>"
|
|
"8. Wait for Mod Organizer 2 to fully open<br>"
|
|
"9. Once Mod Organizer has fully loaded, CLOSE IT completely and return here<br>"
|
|
"<br>Once you have completed ALL the steps above, click OK to continue."
|
|
f"{extra_warning}"
|
|
)
|
|
reply = MessageService.question(self, "Manual Steps Required", msg, safety_level="medium")
|
|
if reply == QMessageBox.Yes:
|
|
self.validate_manual_steps_completion()
|
|
else:
|
|
# User clicked Cancel or closed the dialog - cancel the workflow
|
|
self._safe_append_text("\n🛑 Manual steps cancelled by user. Workflow stopped.")
|
|
# Re-enable all controls when workflow is cancelled
|
|
self._enable_controls_after_operation()
|
|
self.cancel_btn.setVisible(True)
|
|
self.cancel_install_btn.setVisible(False)
|
|
|
|
def _get_mo2_path(self, install_dir, modlist_name):
|
|
"""Get ModOrganizer.exe path, handling Somnium's non-standard structure"""
|
|
mo2_exe_path = os.path.join(install_dir, "ModOrganizer.exe")
|
|
if not os.path.exists(mo2_exe_path) and "somnium" in modlist_name.lower():
|
|
somnium_path = os.path.join(install_dir, "files", "ModOrganizer.exe")
|
|
if os.path.exists(somnium_path):
|
|
mo2_exe_path = somnium_path
|
|
return mo2_exe_path
|
|
|
|
def validate_manual_steps_completion(self):
|
|
"""Validate that manual steps were actually completed and handle retry logic"""
|
|
modlist_name = "TTW Installation"
|
|
install_dir = self.install_dir_edit.text().strip()
|
|
mo2_exe_path = self._get_mo2_path(install_dir, modlist_name)
|
|
|
|
# Add delay to allow Steam filesystem updates to complete
|
|
self._safe_append_text("Waiting for Steam filesystem updates to complete...")
|
|
import time
|
|
time.sleep(2)
|
|
|
|
# CRITICAL: Re-detect the AppID after Steam restart and manual steps
|
|
# Steam assigns a NEW AppID during restart, different from the one we initially created
|
|
self._safe_append_text(f"Re-detecting AppID for shortcut '{modlist_name}' after Steam restart...")
|
|
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
|
|
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
|
|
|
platform_service = PlatformDetectionService.get_instance()
|
|
shortcut_handler = ShortcutHandler(steamdeck=platform_service.is_steamdeck)
|
|
current_appid = shortcut_handler.get_appid_for_shortcut(modlist_name, mo2_exe_path)
|
|
|
|
if not current_appid or not current_appid.isdigit():
|
|
self._safe_append_text(f"Error: Could not find Steam-assigned AppID for shortcut '{modlist_name}'")
|
|
self._safe_append_text("Error: This usually means the shortcut was not launched from Steam")
|
|
self._safe_append_text("Suggestion: Check that Steam is running and shortcuts are visible in library")
|
|
self.handle_validation_failure("Could not find Steam shortcut")
|
|
return
|
|
|
|
self._safe_append_text(f"Found Steam-assigned AppID: {current_appid}")
|
|
self._safe_append_text(f"Validating manual steps completion for AppID: {current_appid}")
|
|
|
|
# Check 1: Proton version
|
|
proton_ok = False
|
|
try:
|
|
from jackify.backend.handlers.modlist_handler import ModlistHandler
|
|
from jackify.backend.handlers.path_handler import PathHandler
|
|
|
|
# Initialize ModlistHandler with correct parameters
|
|
path_handler = PathHandler()
|
|
|
|
# Use centralized Steam Deck detection
|
|
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
|
platform_service = PlatformDetectionService.get_instance()
|
|
|
|
modlist_handler = ModlistHandler(steamdeck=platform_service.is_steamdeck, verbose=False)
|
|
|
|
# Set required properties manually after initialization
|
|
modlist_handler.modlist_dir = install_dir
|
|
modlist_handler.appid = current_appid
|
|
modlist_handler.game_var = "skyrimspecialedition" # Default for now
|
|
|
|
# Set compat_data_path for Proton detection
|
|
compat_data_path_str = path_handler.find_compat_data(current_appid)
|
|
if compat_data_path_str:
|
|
from pathlib import Path
|
|
modlist_handler.compat_data_path = Path(compat_data_path_str)
|
|
|
|
# Check Proton version
|
|
self._safe_append_text(f"Attempting to detect Proton version for AppID {current_appid}...")
|
|
if modlist_handler._detect_proton_version():
|
|
self._safe_append_text(f"Raw detected Proton version: '{modlist_handler.proton_ver}'")
|
|
if modlist_handler.proton_ver and 'experimental' in modlist_handler.proton_ver.lower():
|
|
proton_ok = True
|
|
self._safe_append_text(f"Proton version validated: {modlist_handler.proton_ver}")
|
|
else:
|
|
self._safe_append_text(f"Error: Wrong Proton version detected: '{modlist_handler.proton_ver}' (expected 'experimental' in name)")
|
|
else:
|
|
self._safe_append_text("Error: Could not detect Proton version from any source")
|
|
|
|
except Exception as e:
|
|
self._safe_append_text(f"Error checking Proton version: {e}")
|
|
proton_ok = False
|
|
|
|
# Check 2: Compatdata directory exists
|
|
compatdata_ok = False
|
|
try:
|
|
from jackify.backend.handlers.path_handler import PathHandler
|
|
path_handler = PathHandler()
|
|
|
|
self._safe_append_text(f"Searching for compatdata directory for AppID {current_appid}...")
|
|
self._safe_append_text("Checking standard Steam locations and Flatpak Steam...")
|
|
prefix_path_str = path_handler.find_compat_data(current_appid)
|
|
self._safe_append_text(f"Compatdata search result: '{prefix_path_str}'")
|
|
|
|
if prefix_path_str and os.path.isdir(prefix_path_str):
|
|
compatdata_ok = True
|
|
self._safe_append_text(f"Compatdata directory found: {prefix_path_str}")
|
|
else:
|
|
if prefix_path_str:
|
|
self._safe_append_text(f"Error: Path exists but is not a directory: {prefix_path_str}")
|
|
else:
|
|
self._safe_append_text(f"Error: No compatdata directory found for AppID {current_appid}")
|
|
self._safe_append_text("Suggestion: Ensure you launched the shortcut from Steam at least once")
|
|
self._safe_append_text("Suggestion: Check if Steam is using Flatpak (different file paths)")
|
|
|
|
except Exception as e:
|
|
self._safe_append_text(f"Error checking compatdata: {e}")
|
|
compatdata_ok = False
|
|
|
|
# Handle validation results
|
|
if proton_ok and compatdata_ok:
|
|
self._safe_append_text("Manual steps validation passed!")
|
|
self._safe_append_text("Continuing configuration with updated AppID...")
|
|
|
|
# Continue configuration with the corrected AppID and context
|
|
self.continue_configuration_after_manual_steps(current_appid, modlist_name, install_dir)
|
|
else:
|
|
# Validation failed - handle retry logic
|
|
missing_items = []
|
|
if not proton_ok:
|
|
missing_items.append("• Proton - Experimental not set")
|
|
if not compatdata_ok:
|
|
missing_items.append("• Shortcut not launched from Steam (no compatdata)")
|
|
|
|
missing_text = "\n".join(missing_items)
|
|
self._safe_append_text(f"Manual steps validation failed:\n{missing_text}")
|
|
self.handle_validation_failure(missing_text)
|
|
|
|
def show_shortcut_conflict_dialog(self, conflicts):
|
|
"""Show dialog to resolve shortcut name conflicts"""
|
|
conflict_names = [c['name'] for c in conflicts]
|
|
conflict_info = f"Found existing Steam shortcut: '{conflict_names[0]}'"
|
|
|
|
modlist_name = "TTW Installation"
|
|
|
|
# Create dialog with Jackify styling
|
|
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout
|
|
from PySide6.QtCore import Qt
|
|
|
|
dialog = QDialog(self)
|
|
dialog.setWindowTitle("Steam Shortcut Conflict")
|
|
dialog.setModal(True)
|
|
dialog.resize(450, 180)
|
|
|
|
# Apply Jackify dark theme styling
|
|
dialog.setStyleSheet("""
|
|
QDialog {
|
|
background-color: #2b2b2b;
|
|
color: #ffffff;
|
|
}
|
|
QLabel {
|
|
color: #ffffff;
|
|
font-size: 14px;
|
|
padding: 10px 0px;
|
|
}
|
|
QLineEdit {
|
|
background-color: #404040;
|
|
color: #ffffff;
|
|
border: 2px solid #555555;
|
|
border-radius: 4px;
|
|
padding: 8px;
|
|
font-size: 14px;
|
|
selection-background-color: #3fd0ea;
|
|
}
|
|
QLineEdit:focus {
|
|
border-color: #3fd0ea;
|
|
}
|
|
QPushButton {
|
|
background-color: #404040;
|
|
color: #ffffff;
|
|
border: 2px solid #555555;
|
|
border-radius: 4px;
|
|
padding: 8px 16px;
|
|
font-size: 14px;
|
|
min-width: 120px;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #505050;
|
|
border-color: #3fd0ea;
|
|
}
|
|
QPushButton:pressed {
|
|
background-color: #303030;
|
|
}
|
|
""")
|
|
|
|
layout = QVBoxLayout(dialog)
|
|
layout.setContentsMargins(20, 20, 20, 20)
|
|
layout.setSpacing(15)
|
|
|
|
# Conflict message
|
|
conflict_label = QLabel(f"{conflict_info}\n\nPlease choose a different name for your shortcut:")
|
|
layout.addWidget(conflict_label)
|
|
|
|
# Text input for new name
|
|
name_input = QLineEdit(modlist_name)
|
|
name_input.selectAll()
|
|
layout.addWidget(name_input)
|
|
|
|
# Buttons
|
|
button_layout = QHBoxLayout()
|
|
button_layout.setSpacing(10)
|
|
|
|
create_button = QPushButton("Create with New Name")
|
|
cancel_button = QPushButton("Cancel")
|
|
|
|
button_layout.addStretch()
|
|
button_layout.addWidget(cancel_button)
|
|
button_layout.addWidget(create_button)
|
|
layout.addLayout(button_layout)
|
|
|
|
# Connect signals
|
|
def on_create():
|
|
new_name = name_input.text().strip()
|
|
if new_name and new_name != modlist_name:
|
|
dialog.accept()
|
|
# Retry workflow with new name
|
|
self.retry_automated_workflow_with_new_name(new_name)
|
|
elif new_name == modlist_name:
|
|
# Same name - show warning
|
|
from jackify.frontends.gui.services.message_service import MessageService
|
|
MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.")
|
|
else:
|
|
# Empty name
|
|
from jackify.frontends.gui.services.message_service import MessageService
|
|
MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.")
|
|
|
|
def on_cancel():
|
|
dialog.reject()
|
|
self._safe_append_text("Shortcut creation cancelled by user")
|
|
|
|
create_button.clicked.connect(on_create)
|
|
cancel_button.clicked.connect(on_cancel)
|
|
|
|
# Make Enter key work
|
|
name_input.returnPressed.connect(on_create)
|
|
|
|
dialog.exec()
|
|
|
|
def retry_automated_workflow_with_new_name(self, new_name):
|
|
"""Retry the automated workflow with a new shortcut name"""
|
|
# Update the modlist name field temporarily
|
|
# TTW doesn't need name editing
|
|
|
|
# Restart the automated workflow
|
|
self._safe_append_text(f"Retrying with new shortcut name: '{new_name}'")
|
|
self.start_automated_prefix_workflow()
|
|
|
|
def continue_configuration_after_automated_prefix(self, new_appid, modlist_name, install_dir, last_timestamp=None):
|
|
"""Continue the configuration process with the new AppID after automated prefix creation"""
|
|
# Headers are now shown at start of Steam Integration
|
|
# No need to show them again here
|
|
debug_print("Configuration phase continues after Steam Integration")
|
|
|
|
debug_print(f"continue_configuration_after_automated_prefix called with appid: {new_appid}")
|
|
try:
|
|
# Update the context with the new AppID (same format as manual steps)
|
|
updated_context = {
|
|
'name': modlist_name,
|
|
'path': install_dir,
|
|
'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name),
|
|
'modlist_value': None,
|
|
'modlist_source': None,
|
|
'resolution': getattr(self, '_current_resolution', None),
|
|
'skip_confirmation': True,
|
|
'manual_steps_completed': True, # Mark as completed since automated prefix is done
|
|
'appid': new_appid, # Use the NEW AppID from automated prefix creation
|
|
'game_name': self.context.get('game_name', 'Skyrim Special Edition') if hasattr(self, 'context') else 'Skyrim Special Edition'
|
|
}
|
|
self.context = updated_context # Ensure context is always set
|
|
debug_print(f"Updated context with new AppID: {new_appid}")
|
|
|
|
# Get Steam Deck detection once and pass to ConfigThread
|
|
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
|
platform_service = PlatformDetectionService.get_instance()
|
|
is_steamdeck = platform_service.is_steamdeck
|
|
|
|
# Create new config thread with updated context
|
|
class ConfigThread(QThread):
|
|
progress_update = Signal(str)
|
|
configuration_complete = Signal(bool, str, str)
|
|
error_occurred = Signal(str)
|
|
|
|
def __init__(self, context, is_steamdeck):
|
|
super().__init__()
|
|
self.context = context
|
|
self.is_steamdeck = is_steamdeck
|
|
|
|
def run(self):
|
|
try:
|
|
from jackify.backend.services.modlist_service import ModlistService
|
|
from jackify.backend.models.configuration import SystemInfo
|
|
from jackify.backend.models.modlist import ModlistContext
|
|
from pathlib import Path
|
|
|
|
# Initialize backend service with passed Steam Deck detection
|
|
system_info = SystemInfo(is_steamdeck=self.is_steamdeck)
|
|
modlist_service = ModlistService(system_info)
|
|
|
|
# Convert context to ModlistContext for service
|
|
modlist_context = ModlistContext(
|
|
name=self.context['name'],
|
|
install_dir=Path(self.context['path']),
|
|
download_dir=Path(self.context['path']).parent / 'Downloads', # Default
|
|
game_type='skyrim', # Default for now
|
|
nexus_api_key='', # Not needed for configuration
|
|
modlist_value=self.context.get('modlist_value'),
|
|
modlist_source=self.context.get('modlist_source', 'identifier'),
|
|
resolution=self.context.get('resolution'),
|
|
skip_confirmation=True,
|
|
engine_installed=True # Skip path manipulation for engine workflows
|
|
)
|
|
|
|
# Add app_id to context
|
|
modlist_context.app_id = self.context['appid']
|
|
|
|
# Define callbacks
|
|
def progress_callback(message):
|
|
self.progress_update.emit(message)
|
|
|
|
def completion_callback(success, message, modlist_name):
|
|
self.configuration_complete.emit(success, message, modlist_name)
|
|
|
|
def manual_steps_callback(modlist_name, retry_count):
|
|
# This shouldn't happen since automated prefix creation is complete
|
|
self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
|
|
|
|
# Call the service method for post-Steam configuration
|
|
result = modlist_service.configure_modlist_post_steam(
|
|
context=modlist_context,
|
|
progress_callback=progress_callback,
|
|
manual_steps_callback=manual_steps_callback,
|
|
completion_callback=completion_callback
|
|
)
|
|
|
|
if not result:
|
|
self.progress_update.emit("Configuration failed to start")
|
|
self.error_occurred.emit("Configuration failed to start")
|
|
|
|
except Exception as e:
|
|
self.error_occurred.emit(str(e))
|
|
|
|
# Start configuration thread
|
|
self.config_thread = ConfigThread(updated_context, is_steamdeck)
|
|
self.config_thread.progress_update.connect(self.on_configuration_progress)
|
|
self.config_thread.configuration_complete.connect(self.on_configuration_complete)
|
|
self.config_thread.error_occurred.connect(self.on_configuration_error)
|
|
self.config_thread.start()
|
|
|
|
except Exception as e:
|
|
self._safe_append_text(f"Error continuing configuration: {e}")
|
|
import traceback
|
|
self._safe_append_text(f"Full traceback: {traceback.format_exc()}")
|
|
self.on_configuration_error(str(e))
|
|
|
|
|
|
|
|
def continue_configuration_after_manual_steps(self, new_appid, modlist_name, install_dir):
|
|
"""Continue the configuration process with the corrected AppID after manual steps validation"""
|
|
try:
|
|
# Update the context with the new AppID
|
|
updated_context = {
|
|
'name': modlist_name,
|
|
'path': install_dir,
|
|
'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name),
|
|
'modlist_value': None,
|
|
'modlist_source': None,
|
|
'resolution': getattr(self, '_current_resolution', None),
|
|
'skip_confirmation': True,
|
|
'manual_steps_completed': True, # Mark as completed
|
|
'appid': new_appid # Use the NEW AppID from Steam
|
|
}
|
|
|
|
debug_print(f"Updated context with new AppID: {new_appid}")
|
|
|
|
# Clean up old thread if exists and wait for it to finish
|
|
if hasattr(self, 'config_thread') and self.config_thread is not None:
|
|
# Disconnect all signals to prevent "Internal C++ object already deleted" errors
|
|
try:
|
|
self.config_thread.progress_update.disconnect()
|
|
self.config_thread.configuration_complete.disconnect()
|
|
self.config_thread.error_occurred.disconnect()
|
|
except:
|
|
pass # Ignore errors if already disconnected
|
|
if self.config_thread.isRunning():
|
|
self.config_thread.quit()
|
|
self.config_thread.wait(5000) # Wait up to 5 seconds
|
|
self.config_thread.deleteLater()
|
|
self.config_thread = None
|
|
|
|
# Start new config thread
|
|
self.config_thread = self._create_config_thread(updated_context)
|
|
self.config_thread.progress_update.connect(self.on_configuration_progress)
|
|
self.config_thread.configuration_complete.connect(self.on_configuration_complete)
|
|
self.config_thread.error_occurred.connect(self.on_configuration_error)
|
|
self.config_thread.start()
|
|
|
|
except Exception as e:
|
|
self._safe_append_text(f"Error continuing configuration: {e}")
|
|
self.on_configuration_error(str(e))
|
|
|
|
def _create_config_thread(self, context):
|
|
"""Create a new ConfigThread with proper lifecycle management"""
|
|
from PySide6.QtCore import QThread, Signal
|
|
|
|
# Get Steam Deck detection once
|
|
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
|
platform_service = PlatformDetectionService.get_instance()
|
|
is_steamdeck = platform_service.is_steamdeck
|
|
|
|
class ConfigThread(QThread):
|
|
progress_update = Signal(str)
|
|
configuration_complete = Signal(bool, str, str)
|
|
error_occurred = Signal(str)
|
|
|
|
def __init__(self, context, is_steamdeck, parent=None):
|
|
super().__init__(parent)
|
|
self.context = context
|
|
self.is_steamdeck = is_steamdeck
|
|
|
|
def run(self):
|
|
try:
|
|
from jackify.backend.models.configuration import SystemInfo
|
|
from jackify.backend.services.modlist_service import ModlistService
|
|
from jackify.backend.models.modlist import ModlistContext
|
|
from pathlib import Path
|
|
|
|
# Initialize backend service with passed Steam Deck detection
|
|
system_info = SystemInfo(is_steamdeck=self.is_steamdeck)
|
|
modlist_service = ModlistService(system_info)
|
|
|
|
# Convert context to ModlistContext for service
|
|
modlist_context = ModlistContext(
|
|
name=self.context['name'],
|
|
install_dir=Path(self.context['path']),
|
|
download_dir=Path(self.context['path']).parent / 'Downloads', # Default
|
|
game_type='skyrim', # Default for now
|
|
nexus_api_key='', # Not needed for configuration
|
|
modlist_value=self.context.get('modlist_value', ''),
|
|
modlist_source=self.context.get('modlist_source', 'identifier'),
|
|
resolution=self.context.get('resolution'), # Pass resolution from GUI
|
|
skip_confirmation=True,
|
|
engine_installed=True # Skip path manipulation for engine workflows
|
|
)
|
|
|
|
# Add app_id to context
|
|
if 'appid' in self.context:
|
|
modlist_context.app_id = self.context['appid']
|
|
|
|
# Define callbacks
|
|
def progress_callback(message):
|
|
self.progress_update.emit(message)
|
|
|
|
def completion_callback(success, message, modlist_name):
|
|
self.configuration_complete.emit(success, message, modlist_name)
|
|
|
|
def manual_steps_callback(modlist_name, retry_count):
|
|
# This shouldn't happen since manual steps should be done
|
|
self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
|
|
|
|
# Call the new service method for post-Steam configuration
|
|
result = modlist_service.configure_modlist_post_steam(
|
|
context=modlist_context,
|
|
progress_callback=progress_callback,
|
|
manual_steps_callback=manual_steps_callback,
|
|
completion_callback=completion_callback
|
|
)
|
|
|
|
if not result:
|
|
self.progress_update.emit("WARNING: configure_modlist_post_steam returned False")
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
error_details = f"Error in configuration: {e}\nTraceback: {traceback.format_exc()}"
|
|
self.progress_update.emit(f"DEBUG: {error_details}")
|
|
self.error_occurred.emit(str(e))
|
|
|
|
return ConfigThread(context, is_steamdeck, parent=self)
|
|
|
|
def handle_validation_failure(self, missing_text):
|
|
"""Handle failed validation with retry logic"""
|
|
self._manual_steps_retry_count += 1
|
|
|
|
if self._manual_steps_retry_count < 3:
|
|
# Show retry dialog with increasingly detailed guidance
|
|
retry_guidance = ""
|
|
if self._manual_steps_retry_count == 1:
|
|
retry_guidance = "\n\nTip: Make sure Steam is fully restarted before trying again."
|
|
elif self._manual_steps_retry_count == 2:
|
|
retry_guidance = "\n\nTip: If using Flatpak Steam, ensure compatdata is being created in the correct location."
|
|
|
|
MessageService.critical(self, "Manual Steps Incomplete",
|
|
f"Manual steps validation failed:\n\n{missing_text}\n\n"
|
|
f"Please complete the missing steps and try again.{retry_guidance}")
|
|
# Show manual steps dialog again
|
|
extra_warning = ""
|
|
if self._manual_steps_retry_count >= 2:
|
|
extra_warning = "<br><b style='color:#f33'>It looks like you have not completed the manual steps yet. Please try again.</b>"
|
|
self.show_manual_steps_dialog(extra_warning)
|
|
else:
|
|
# Max retries reached
|
|
MessageService.critical(self, "Manual Steps Failed",
|
|
"Manual steps validation failed after multiple attempts.\n\n"
|
|
"Common issues:\n"
|
|
"• Steam not fully restarted\n"
|
|
"• Shortcut not launched from Steam\n"
|
|
"• Flatpak Steam using different file paths\n"
|
|
"• Proton - Experimental not selected")
|
|
self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self._current_modlist_name)
|
|
|
|
def show_next_steps_dialog(self, message):
|
|
# EXACT LEGACY show_next_steps_dialog
|
|
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout, QApplication
|
|
dlg = QDialog(self)
|
|
dlg.setWindowTitle("Next Steps")
|
|
dlg.setModal(True)
|
|
layout = QVBoxLayout(dlg)
|
|
label = QLabel(message)
|
|
label.setWordWrap(True)
|
|
layout.addWidget(label)
|
|
btn_row = QHBoxLayout()
|
|
btn_return = QPushButton("Return")
|
|
btn_exit = QPushButton("Exit")
|
|
btn_row.addWidget(btn_return)
|
|
btn_row.addWidget(btn_exit)
|
|
layout.addLayout(btn_row)
|
|
def on_return():
|
|
dlg.accept()
|
|
if self.stacked_widget:
|
|
self.stacked_widget.setCurrentIndex(0) # Main menu
|
|
def on_exit():
|
|
QApplication.quit()
|
|
btn_return.clicked.connect(on_return)
|
|
btn_exit.clicked.connect(on_exit)
|
|
dlg.exec()
|
|
|
|
def cleanup_processes(self):
|
|
"""Clean up any running processes when the window closes or is cancelled"""
|
|
debug_print("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes")
|
|
|
|
# Clean up InstallationThread if running
|
|
if hasattr(self, 'install_thread') and self.install_thread.isRunning():
|
|
debug_print("DEBUG: Cancelling running InstallationThread")
|
|
self.install_thread.cancel()
|
|
self.install_thread.wait(3000) # Wait up to 3 seconds
|
|
if self.install_thread.isRunning():
|
|
self.install_thread.terminate()
|
|
|
|
# Clean up other threads
|
|
threads = [
|
|
'prefix_thread', 'config_thread', 'fetch_thread'
|
|
]
|
|
for thread_name in threads:
|
|
if hasattr(self, thread_name):
|
|
thread = getattr(self, thread_name)
|
|
if thread and thread.isRunning():
|
|
debug_print(f"DEBUG: Terminating {thread_name}")
|
|
thread.terminate()
|
|
thread.wait(1000) # Wait up to 1 second
|
|
|
|
def _perform_modlist_integration(self):
|
|
"""Integrate TTW into the modlist automatically
|
|
|
|
This is called when in integration mode. It will:
|
|
1. Copy TTW output to modlist's mods folder
|
|
2. Update modlist.txt for all profiles
|
|
3. Update plugins.txt with TTW ESMs in correct order
|
|
4. Emit integration_complete signal
|
|
"""
|
|
try:
|
|
from pathlib import Path
|
|
import re
|
|
from PySide6.QtCore import QThread, Signal
|
|
|
|
# Get TTW output directory
|
|
ttw_output_dir = Path(self.install_dir_edit.text())
|
|
if not ttw_output_dir.exists():
|
|
error_msg = f"TTW output directory not found: {ttw_output_dir}"
|
|
self._safe_append_text(f"\nError: {error_msg}")
|
|
self.integration_complete.emit(False, "")
|
|
return
|
|
|
|
# Extract version from .mpi filename
|
|
mpi_path = self.file_edit.text().strip()
|
|
ttw_version = ""
|
|
if mpi_path:
|
|
mpi_filename = Path(mpi_path).stem
|
|
version_match = re.search(r'v?(\d+\.\d+(?:\.\d+)?)', mpi_filename, re.IGNORECASE)
|
|
if version_match:
|
|
ttw_version = version_match.group(1)
|
|
|
|
# Create background thread for integration
|
|
class IntegrationThread(QThread):
|
|
finished = Signal(bool, str) # success, ttw_version
|
|
progress = Signal(str) # progress message
|
|
|
|
def __init__(self, ttw_output_path, modlist_install_dir, ttw_version):
|
|
super().__init__()
|
|
self.ttw_output_path = ttw_output_path
|
|
self.modlist_install_dir = modlist_install_dir
|
|
self.ttw_version = ttw_version
|
|
|
|
def run(self):
|
|
try:
|
|
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
|
|
|
|
self.progress.emit("Integrating TTW into modlist...")
|
|
success = TTWInstallerHandler.integrate_ttw_into_modlist(
|
|
ttw_output_path=self.ttw_output_path,
|
|
modlist_install_dir=self.modlist_install_dir,
|
|
ttw_version=self.ttw_version
|
|
)
|
|
self.finished.emit(success, self.ttw_version)
|
|
except Exception as e:
|
|
debug_print(f"ERROR: Integration thread failed: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
self.finished.emit(False, self.ttw_version)
|
|
|
|
# Show progress message
|
|
self._safe_append_text("\nIntegrating TTW into modlist (this may take a few minutes)...")
|
|
|
|
# Update status banner (only in integration mode - visible when collapsed)
|
|
if self._integration_mode:
|
|
self.status_banner.setText("Integrating TTW into modlist (this may take a few minutes)...")
|
|
self.status_banner.setStyleSheet(f"""
|
|
QLabel {{
|
|
background-color: #FFA500;
|
|
color: white;
|
|
font-weight: bold;
|
|
padding: 8px;
|
|
border-radius: 5px;
|
|
}}
|
|
""")
|
|
|
|
# Create progress dialog for integration
|
|
progress_dialog = QProgressDialog(
|
|
f"Integrating TTW {ttw_version} into modlist...\n\n"
|
|
"This involves copying several GB of files and may take a few minutes.\n"
|
|
"Please wait...",
|
|
None, # No cancel button
|
|
0, 0, # Indeterminate progress
|
|
self
|
|
)
|
|
progress_dialog.setWindowTitle("Integrating TTW")
|
|
progress_dialog.setMinimumDuration(0) # Show immediately
|
|
progress_dialog.setWindowModality(Qt.ApplicationModal)
|
|
progress_dialog.setCancelButton(None)
|
|
progress_dialog.show()
|
|
QApplication.processEvents()
|
|
|
|
# Store reference to close later
|
|
self._integration_progress_dialog = progress_dialog
|
|
|
|
# Create and start integration thread
|
|
self.integration_thread = IntegrationThread(
|
|
ttw_output_dir,
|
|
Path(self._integration_install_dir),
|
|
ttw_version
|
|
)
|
|
self.integration_thread.progress.connect(self._safe_append_text)
|
|
self.integration_thread.finished.connect(self._on_integration_thread_finished)
|
|
self.integration_thread.start()
|
|
|
|
except Exception as e:
|
|
# Close progress dialog if it exists
|
|
if hasattr(self, '_integration_progress_dialog'):
|
|
self._integration_progress_dialog.close()
|
|
delattr(self, '_integration_progress_dialog')
|
|
|
|
error_msg = f"Integration error: {str(e)}"
|
|
self._safe_append_text(f"\nError: {error_msg}")
|
|
debug_print(f"ERROR: {error_msg}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
self.integration_complete.emit(False, "")
|
|
|
|
def _on_integration_thread_finished(self, success: bool, ttw_version: str):
|
|
"""Handle completion of integration thread"""
|
|
try:
|
|
# Close progress dialog
|
|
if hasattr(self, '_integration_progress_dialog'):
|
|
self._integration_progress_dialog.close()
|
|
delattr(self, '_integration_progress_dialog')
|
|
|
|
if success:
|
|
self._safe_append_text("\nTTW integration completed successfully!")
|
|
|
|
# Update status banner (only in integration mode)
|
|
if self._integration_mode:
|
|
self.status_banner.setText("TTW integration completed successfully!")
|
|
self.status_banner.setStyleSheet(f"""
|
|
QLabel {{
|
|
background-color: #28a745;
|
|
color: white;
|
|
font-weight: bold;
|
|
padding: 8px;
|
|
border-radius: 5px;
|
|
}}
|
|
""")
|
|
|
|
MessageService.information(
|
|
self, "Integration Complete",
|
|
f"TTW {ttw_version} has been successfully integrated into {self._integration_modlist_name}!",
|
|
safety_level="medium"
|
|
)
|
|
self.integration_complete.emit(True, ttw_version)
|
|
else:
|
|
self._safe_append_text("\nTTW integration failed!")
|
|
|
|
# Update status banner (only in integration mode)
|
|
if self._integration_mode:
|
|
self.status_banner.setText("TTW integration failed!")
|
|
self.status_banner.setStyleSheet(f"""
|
|
QLabel {{
|
|
background-color: #dc3545;
|
|
color: white;
|
|
font-weight: bold;
|
|
padding: 8px;
|
|
border-radius: 5px;
|
|
}}
|
|
""")
|
|
|
|
MessageService.critical(
|
|
self, "Integration Failed",
|
|
"Failed to integrate TTW into the modlist. Check the log for details."
|
|
)
|
|
self.integration_complete.emit(False, ttw_version)
|
|
except Exception as e:
|
|
debug_print(f"ERROR: Failed to handle integration completion: {e}")
|
|
self.integration_complete.emit(False, ttw_version)
|
|
|
|
def _create_ttw_mod_archive(self, automated=False):
|
|
"""Create a zipped mod archive of TTW output for MO2 installation.
|
|
|
|
Args:
|
|
automated: If True, runs silently without user prompts (for automation)
|
|
"""
|
|
try:
|
|
from pathlib import Path
|
|
import re
|
|
from PySide6.QtCore import QThread, Signal
|
|
|
|
output_dir = Path(self.install_dir_edit.text())
|
|
if not output_dir.exists():
|
|
if not automated:
|
|
MessageService.warning(self, "Output Directory Not Found",
|
|
f"Output directory does not exist:\n{output_dir}")
|
|
return False
|
|
|
|
# Extract version from .mpi filename (e.g., "TTW v3.4.mpi" -> "3.4")
|
|
mpi_path = self.file_edit.text().strip()
|
|
version_suffix = ""
|
|
if mpi_path:
|
|
mpi_filename = Path(mpi_path).stem
|
|
version_match = re.search(r'v?(\d+\.\d+(?:\.\d+)?)', mpi_filename, re.IGNORECASE)
|
|
if version_match:
|
|
version_suffix = f" {version_match.group(1)}"
|
|
|
|
# Create archive filename
|
|
archive_name = f"[NoDelete] Tale of Two Wastelands{version_suffix}"
|
|
archive_path = output_dir.parent / archive_name
|
|
|
|
# Create background thread for zip creation
|
|
class ZipCreationThread(QThread):
|
|
finished = Signal(bool, str) # success, result_message
|
|
|
|
def __init__(self, output_dir, archive_path):
|
|
super().__init__()
|
|
self.output_dir = output_dir
|
|
self.archive_path = archive_path
|
|
|
|
def run(self):
|
|
try:
|
|
import shutil
|
|
final_archive = shutil.make_archive(
|
|
str(self.archive_path),
|
|
'zip',
|
|
str(self.output_dir)
|
|
)
|
|
self.finished.emit(True, str(final_archive))
|
|
except Exception as e:
|
|
self.finished.emit(False, str(e))
|
|
|
|
# Create progress dialog (non-modal so UI stays responsive)
|
|
progress_dialog = QProgressDialog(
|
|
f"Creating mod archive: {archive_name}.zip\n\n"
|
|
"This may take several minutes depending on installation size...",
|
|
"Cancel",
|
|
0, 0, # 0,0 = indeterminate progress bar
|
|
self
|
|
)
|
|
progress_dialog.setWindowTitle("Creating Archive")
|
|
progress_dialog.setMinimumDuration(0) # Show immediately
|
|
progress_dialog.setWindowModality(Qt.ApplicationModal)
|
|
progress_dialog.setCancelButton(None) # Cannot cancel zip operation safely
|
|
progress_dialog.show()
|
|
QApplication.processEvents()
|
|
|
|
# Create and start thread
|
|
zip_thread = ZipCreationThread(output_dir, archive_path)
|
|
|
|
def on_zip_finished(success, result):
|
|
progress_dialog.close()
|
|
if success:
|
|
final_archive = result
|
|
if not automated:
|
|
self._safe_append_text(f"\nArchive created successfully: {Path(final_archive).name}")
|
|
MessageService.information(
|
|
self, "Archive Created",
|
|
f"TTW mod archive created successfully!\n\n"
|
|
f"Location: {final_archive}\n\n"
|
|
f"You can now install this archive as a mod in MO2.",
|
|
safety_level="medium"
|
|
)
|
|
else:
|
|
error_msg = f"Failed to create mod archive: {result}"
|
|
if not automated:
|
|
self._safe_append_text(f"\nError: {error_msg}")
|
|
MessageService.critical(self, "Archive Creation Failed", error_msg)
|
|
|
|
zip_thread.finished.connect(on_zip_finished)
|
|
zip_thread.start()
|
|
|
|
# Keep reference to prevent garbage collection
|
|
self._zip_thread = zip_thread
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
error_msg = f"Failed to create mod archive: {str(e)}"
|
|
if not automated:
|
|
self._safe_append_text(f"\nError: {error_msg}")
|
|
MessageService.critical(self, "Archive Creation Failed", error_msg)
|
|
return False
|
|
|
|
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)
|
|
|
|
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"""<b>Somnium Post-Installation Required</b><br><br>
|
|
Due to Somnium's non-standard folder structure, you need to manually update the binary paths in ModOrganizer:<br><br>
|
|
<b>1.</b> Launch the Steam shortcut created for Somnium<br>
|
|
<b>2.</b> In ModOrganizer, go to Settings → Executables<br>
|
|
<b>3.</b> For each executable entry (SKSE64, etc.), update the binary path to point to:<br>
|
|
<code>{self._somnium_install_dir}/files/root/Enderal Special Edition/skse64_loader.exe</code><br><br>
|
|
<b>Note:</b> Full Somnium support will be added in a future Jackify update.<br><br>
|
|
<i>You can also refer to the Somnium installation guide at:<br>
|
|
https://wiki.scenicroute.games/Somnium/1_Installation.html</i>"""
|
|
|
|
MessageService.information(self, "Somnium Setup Required", guidance_text)
|
|
|
|
# Reset the guidance flag
|
|
self._show_somnium_guidance = False
|
|
self._somnium_install_dir = None
|
|
|
|
def cancel_and_cleanup(self):
|
|
"""Handle Cancel button - clean up processes and go back"""
|
|
self.cleanup_processes()
|
|
# Restore main window to standard Jackify size before leaving
|
|
try:
|
|
main_window = self.window()
|
|
if main_window:
|
|
from PySide6.QtCore import QSize
|
|
|
|
# Only set minimum size - DO NOT RESIZE
|
|
main_window.setMaximumSize(QSize(16777215, 16777215))
|
|
set_responsive_minimum(main_window, min_width=960, min_height=420)
|
|
# DO NOT resize - let window stay at current size
|
|
|
|
# Ensure we exit in collapsed state so next entry starts compact (both Desktop and Deck)
|
|
if self.show_details_checkbox.isChecked():
|
|
self.show_details_checkbox.blockSignals(True)
|
|
self.show_details_checkbox.setChecked(False)
|
|
self.show_details_checkbox.blockSignals(False)
|
|
# Only toggle console visibility on Desktop (on Deck it's always visible)
|
|
if not is_steamdeck:
|
|
self._toggle_console_visibility(_Qt.Unchecked)
|
|
except Exception:
|
|
pass
|
|
self.go_back()
|
|
|
|
def reset_screen_to_defaults(self):
|
|
"""Reset the screen to default state when navigating back from main menu"""
|
|
# Reset form fields
|
|
self.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()
|
|
|
|
|