Files
Jackify/jackify/frontends/gui/screens/install_modlist.py
2026-02-07 18:26:54 +00:00

576 lines
29 KiB
Python

"""
InstallModlistScreen for Jackify GUI
"""
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QApplication, QCheckBox, QStyledItemDelegate, QStyle, QTableWidget, QTableWidgetItem, QHeaderView, QMainWindow
from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject, QUrl
from PySide6.QtGui import QPixmap, QTextCursor, QColor, QPainter, QFont
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from ..utils import ansi_to_html, set_responsive_minimum
from ..widgets.unsupported_game_dialog import UnsupportedGameDialog
import os
import subprocess
import sys
import threading
from typing import Optional
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
from jackify.backend.handlers.wabbajack_parser import WabbajackParser
import traceback
from jackify.backend.core.modlist_operations import get_jackify_engine_path
import signal
import re
import time
from jackify.backend.handlers.subprocess_utils import ProcessManager
from jackify.backend.handlers.config_handler import ConfigHandler
from ..dialogs import SuccessDialog
from jackify.backend.handlers.validation_handler import ValidationHandler
from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog
from jackify.frontends.gui.services.message_service import MessageService
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
# R&D: Progress reporting components
from jackify.backend.handlers.progress_parser import ProgressStateManager
from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
from jackify.shared.progress_models import InstallationPhase, InstallationProgress, OperationType, FileProgress
# Modlist gallery (imported at module level to avoid import delay when opening dialog)
from jackify.frontends.gui.screens.modlist_gallery import ModlistGalleryDialog
from .install_modlist_dialogs import ModlistFetchThread, SelectionDialog
from .install_modlist_ui_setup import InstallModlistUISetupMixin
from .install_modlist_console import ConsoleOutputMixin
from .install_modlist_progress import ProgressHandlersMixin
from .install_modlist_postinstall import PostInstallFeedbackMixin
from .install_modlist_automated_prefix import AutomatedPrefixHandlersMixin
from .install_modlist_configuration import ConfigurationPhaseMixin
from .install_modlist_ttw import TTWIntegrationMixin
from .install_modlist_vnv import VNVAutomationMixin
from .install_modlist_workflow import InstallWorkflowMixin
from .install_modlist_nexus import NexusAuthMixin
from .install_modlist_selection import ModlistSelectionMixin
from .screen_back_mixin import ScreenBackMixin
def debug_print(message):
"""Print debug message only if debug mode is enabled"""
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
if config_handler.get('debug_mode', False):
print(message)
class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleOutputMixin, ProgressHandlersMixin, PostInstallFeedbackMixin, AutomatedPrefixHandlersMixin, ConfigurationPhaseMixin, QWidget, TTWIntegrationMixin, VNVAutomationMixin, InstallWorkflowMixin, NexusAuthMixin, ModlistSelectionMixin):
steam_restart_finished = Signal(bool, str)
resize_request = Signal(str) # Signal for expand/collapse like TTW screen
def _collect_actionable_controls(self):
"""Collect all actionable controls that should be disabled during operations (except Cancel)"""
self._actionable_controls = [
# Main action button
self.start_btn,
# Game/modlist selection
self.game_type_btn,
self.modlist_btn,
# Source tabs (entire tab widget)
self.source_tabs,
# Form fields
self.modlist_name_edit,
self.install_dir_edit,
self.downloads_dir_edit,
self.file_edit,
# Browse buttons
self.browse_install_btn,
self.browse_downloads_btn,
self.file_btn,
# Resolution controls
self.resolution_combo,
# Nexus login button
self.nexus_login_btn,
# Checkboxes
self.auto_restart_checkbox,
]
def _disable_controls_during_operation(self):
"""Disable all actionable controls during install/configure operations (except Cancel)"""
for control in self._actionable_controls:
if control:
control.setEnabled(False)
def _enable_controls_after_operation(self):
"""Re-enable all actionable controls after install/configure operations complete"""
for control in self._actionable_controls:
if control:
control.setEnabled(True)
def _abort_install_validation(self):
"""Reset UI state when validation is aborted early."""
self._enable_controls_after_operation()
self.cancel_btn.setVisible(True)
self.cancel_install_btn.setVisible(False)
self.progress_indicator.reset()
self.process_monitor.clear()
def _abort_with_message(self, level: str, title: str, message: str, **kwargs):
"""Show a message and abort the validation workflow."""
messenger = getattr(MessageService, level, MessageService.warning)
messenger(self, title, message, **kwargs)
self._abort_install_validation()
def refresh_paths(self):
"""Refresh cached paths when config changes."""
from jackify.shared.paths import get_jackify_logs_dir
self.modlist_log_path = get_jackify_logs_dir() / 'Modlist_Install_workflow.log'
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
def _open_url_safe(self, url):
"""Safely open URL via subprocess to avoid Qt library clashes inside the AppImage runtime"""
import subprocess
try:
subprocess.Popen(['xdg-open', url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception as e:
print(f"Warning: Could not open URL {url}: {e}")
def resizeEvent(self, event):
"""Handle window resize to prioritize form over console"""
super().resizeEvent(event)
self._adjust_console_for_form_priority()
def _adjust_console_for_form_priority(self):
"""Console now dynamically fills available space with stretch=1, no manual calculation needed"""
# The console automatically fills remaining space due to stretch=1 in the layout
# Remove any fixed height constraints to allow natural stretching
self.console.setMaximumHeight(16777215) # Reset to default maximum
self.console.setMinimumHeight(50) # Keep minimum height for usability
def showEvent(self, event):
"""Called when the widget becomes visible - ensure collapsed state"""
super().showEvent(event)
# Refresh Nexus auth status when screen becomes visible
# Refresh auth status after OAuth from Settings
self._update_nexus_status()
# Do NOT load saved parent directories
# Gallery cache preloads at app startup (see JackifyMainWindow.__init__)
# Ensure initial collapsed layout each time this screen is opened (like TTW screen)
try:
from PySide6.QtCore import Qt as _Qt
# Ensure checkbox is unchecked without emitting signals
if self.show_details_checkbox.isChecked():
self.show_details_checkbox.blockSignals(True)
self.show_details_checkbox.setChecked(False)
self.show_details_checkbox.blockSignals(False)
# Force collapsed state
self._toggle_console_visibility(_Qt.Unchecked)
# Force the window to compact height
main_window = self.window()
if main_window:
# Save original geometry once
if self._saved_geometry is None:
self._saved_geometry = main_window.geometry()
if self._saved_min_size is None:
self._saved_min_size = main_window.minimumSize()
# Use Qt's standard approach: let layout size naturally, only set minimum
# Allow manual resizing, prevent content cut-off
from PySide6.QtCore import QTimer, QSize
from PySide6.QtWidgets import QApplication
def calculate_and_set_upper_section_height():
"""Calculate and lock the upper section height based on left side only"""
try:
if hasattr(self, 'upper_section_widget') and self.upper_section_widget is not None:
# Only calculate if we haven't stored it yet
if not hasattr(self, '_upper_section_fixed_height') or self._upper_section_fixed_height is None:
# Calculate height based on LEFT side (user_config_widget) only
if hasattr(self, 'user_config_widget') and self.user_config_widget is not None:
# Force layout updates to ensure everything is calculated
self.user_config_widget.updateGeometry()
self.user_config_widget.layout().update()
self.updateGeometry()
self.layout().update()
QApplication.processEvents()
# Get the natural height of the left side
left_height = self.user_config_widget.sizeHint().height()
# Add a small margin for spacing
self._upper_section_fixed_height = left_height + 20
else:
# Fallback: use sizeHint of upper section
self.upper_section_widget.updateGeometry()
self._upper_section_fixed_height = self.upper_section_widget.sizeHint().height()
# Lock the height - same in both modes
self.upper_section_widget.setMaximumHeight(self._upper_section_fixed_height)
self.upper_section_widget.setMinimumHeight(self._upper_section_fixed_height)
except Exception as e:
if self.debug:
print(f"DEBUG: Error calculating upper section height: {e}")
pass
# Calculate heights immediately after forcing layout update
# Prevents visible layout shift
self.updateGeometry()
self.layout().update()
QApplication.processEvents()
# Calculate upper section height immediately
calculate_and_set_upper_section_height()
# Only set minimum size - DO NOT RESIZE
from PySide6.QtCore import QSize
# On Steam Deck, keep fullscreen; on other systems, set normal window state
if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck):
main_window.showNormal()
main_window.setMaximumSize(QSize(16777215, 16777215))
set_responsive_minimum(main_window, min_width=960, min_height=420)
# DO NOT resize - let window stay at current size
except Exception as e:
debug_print(f"DEBUG: showEvent exception: {e}")
def _start_gallery_cache_preload(self):
"""DEPRECATED: Gallery cache preload now happens at app startup in JackifyMainWindow"""
# Only start once per session
if self._gallery_cache_preload_started:
return
self._gallery_cache_preload_started = True
# Create background thread to preload gallery cache
class GalleryCachePreloadThread(QThread):
finished_signal = Signal(bool, str) # success, message
def run(self):
try:
from jackify.backend.services.modlist_gallery_service import ModlistGalleryService
service = ModlistGalleryService()
# Fetch with search index to build cache (this will take time but is invisible)
# Use force_refresh=False to allow using existing cache if it has mods
metadata = service.fetch_modlist_metadata(
include_validation=False, # Skip validation for speed
include_search_index=True, # Include mods for search
sort_by="title",
force_refresh=False # Use cache if it has mods, otherwise fetch fresh
)
if metadata:
# Check if we got mods
modlists_with_mods = sum(1 for m in metadata.modlists if hasattr(m, 'mods') and m.mods)
if modlists_with_mods > 0:
debug_print(f"DEBUG: Gallery cache ready ({modlists_with_mods} modlists with mods)")
else:
# Cache didn't have mods, but we fetched fresh - should have mods now
debug_print("DEBUG: Gallery cache updated")
else:
debug_print("DEBUG: Failed to load gallery cache")
except Exception as e:
debug_print(f"DEBUG: Gallery cache preload error: {str(e)}")
# Start thread (non-blocking, invisible to user)
self._gallery_cache_preload_thread = GalleryCachePreloadThread()
# Don't connect finished signal - we don't need to do anything, just let it run
self._gallery_cache_preload_thread.start()
debug_print("DEBUG: Started background gallery cache preload")
def hideEvent(self, event):
"""Called when the widget is hidden. Do not clear main window constraints so collapse from go_back() sticks."""
super().hideEvent(event)
def _load_saved_parent_directories(self):
"""No-op: do not pre-populate install/download directories from saved values."""
pass
def _update_directory_suggestions(self, modlist_name):
"""Update directory suggestions based on modlist name"""
try:
if not modlist_name:
return
# Update install directory suggestion with modlist name
saved_install_parent = self.config_handler.get_default_install_parent_dir()
if saved_install_parent:
suggested_install_dir = os.path.join(saved_install_parent, modlist_name)
self.install_dir_edit.setText(suggested_install_dir)
debug_print(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}")
# Update download directory suggestion
saved_download_parent = self.config_handler.get_default_download_parent_dir()
if saved_download_parent:
suggested_download_dir = os.path.join(saved_download_parent, "Downloads")
self.downloads_dir_edit.setText(suggested_download_dir)
debug_print(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}")
except Exception as e:
debug_print(f"DEBUG: Error updating directory suggestions: {e}")
def _save_parent_directories(self, install_dir, downloads_dir):
"""Removed automatic saving - user should set defaults in settings"""
pass
def update_top_panel(self):
try:
result = subprocess.run([
"ps", "-eo", "pcpu,pmem,comm,args"
], stdout=subprocess.PIPE, text=True, timeout=2)
lines = result.stdout.splitlines()
header = "CPU%\tMEM%\tCOMMAND"
filtered = [header]
process_rows = []
for line in lines[1:]:
line_lower = line.lower()
if (
("jackify-engine" in line_lower or "7zz" in line_lower or "texconv" in line_lower or
"wine" in line_lower or "wine64" in line_lower or "protontricks" in line_lower or
"hoolamike" in line_lower)
and "jackify-gui.py" not in line_lower
):
cols = line.strip().split(None, 3)
if len(cols) >= 3:
process_rows.append(cols)
process_rows.sort(key=lambda x: float(x[0]), reverse=True)
for cols in process_rows:
filtered.append('\t'.join(cols))
if len(filtered) == 1:
filtered.append("[No Jackify-related processes found]")
self.process_monitor.setPlainText('\n'.join(filtered))
except Exception as e:
self.process_monitor.setPlainText(f"[process info unavailable: {e}]")
def _check_protontricks(self):
"""Check if protontricks is available before critical operations"""
try:
if self.protontricks_service.is_bundled_mode():
return True
is_installed, installation_type, details = self.protontricks_service.detect_protontricks()
if not is_installed:
# Show protontricks error dialog
from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog
dialog = ProtontricksErrorDialog(self.protontricks_service, self)
result = dialog.exec()
if result == QDialog.Rejected:
return False
# Re-check after dialog
is_installed, _, _ = self.protontricks_service.detect_protontricks(use_cache=False)
return is_installed
return True
except Exception as e:
print(f"Error checking protontricks: {e}")
MessageService.warning(self, "Protontricks Check Failed",
f"Unable to verify protontricks installation: {e}\n\n"
"Continuing anyway, but some features may not work correctly.")
return True # Continue anyway
def _on_show_details_toggled(self, checked: bool):
"""R&D: Toggle console visibility (reuse TTW pattern)"""
from PySide6.QtCore import Qt as _Qt
self._toggle_console_visibility(_Qt.Checked if checked else _Qt.Unchecked)
def 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):
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout, QApplication
dlg = QDialog(self)
dlg.setWindowTitle("Next Steps")
dlg.setModal(True)
layout = QVBoxLayout(dlg)
label = QLabel(message)
label.setWordWrap(True)
layout.addWidget(label)
btn_row = QHBoxLayout()
btn_return = QPushButton("Return")
btn_exit = QPushButton("Exit")
btn_row.addWidget(btn_return)
btn_row.addWidget(btn_exit)
layout.addLayout(btn_row)
def on_return():
dlg.accept()
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(0) # Main menu
def on_exit():
QApplication.quit()
btn_return.clicked.connect(on_return)
btn_exit.clicked.connect(on_exit)
dlg.exec()
def cleanup_processes(self):
"""Clean up any running processes when the window closes or is cancelled"""
debug_print("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes")
# Clean up InstallationThread if running
if hasattr(self, 'install_thread') and self.install_thread.isRunning():
debug_print("DEBUG: Cancelling running InstallationThread")
self.install_thread.cancel()
self.install_thread.wait(3000) # Wait up to 3 seconds
if self.install_thread.isRunning():
self.install_thread.terminate()
# Clean up other threads
threads = [
'prefix_thread', 'config_thread', 'fetch_thread'
]
for thread_name in threads:
if hasattr(self, thread_name):
thread = getattr(self, thread_name)
if thread and thread.isRunning():
debug_print(f"DEBUG: Terminating {thread_name}")
thread.terminate()
thread.wait(1000) # Wait up to 1 second
def cancel_installation(self):
"""Cancel the currently running installation"""
reply = MessageService.question(
self, "Cancel Installation",
"Are you sure you want to cancel the installation?",
critical=False # Non-critical, won't steal focus
)
if reply == QMessageBox.Yes:
self._safe_append_text("\nCancelling installation...")
# Set flag so we can detect cancellation reliably
self._cancellation_requested = True
try:
# Clear Active Files window and reset progress indicator
if hasattr(self, 'file_progress_list'):
self.file_progress_list.clear()
if hasattr(self, 'progress_indicator'):
self.progress_indicator.reset()
# Cancel the installation thread if it exists
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
self.install_thread.cancel()
self.install_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown
if self.install_thread.isRunning():
self.install_thread.terminate() # Force terminate if needed
self.install_thread.wait(1000)
# Cancel the automated prefix thread if it exists
if hasattr(self, 'prefix_thread') and self.prefix_thread and self.prefix_thread.isRunning():
self.prefix_thread.terminate()
self.prefix_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown
if self.prefix_thread.isRunning():
self.prefix_thread.terminate() # Force terminate if needed
self.prefix_thread.wait(1000)
# Cancel the configuration thread if it exists
if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning():
self.config_thread.terminate()
self.config_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown
if self.config_thread.isRunning():
self.config_thread.terminate() # Force terminate if needed
self.config_thread.wait(1000)
# Cleanup any remaining processes
self.cleanup_processes()
# Reset button states and re-enable all controls
self._enable_controls_after_operation()
self.cancel_btn.setVisible(True)
self.cancel_install_btn.setVisible(False)
# Collapse window if "Show Details" is checked
if hasattr(self, 'show_details_checkbox') and self.show_details_checkbox.isChecked():
self.resize_request.emit('collapse')
self.show_details_checkbox.blockSignals(True)
self.show_details_checkbox.setChecked(False)
self.show_details_checkbox.blockSignals(False)
except Exception as e:
debug_print(f"ERROR: Exception during cancellation cleanup: {e}")
import traceback
traceback.print_exc()
finally:
# Always write cancellation message to console so detection works
self._safe_append_text("Installation cancelled by user.")
def _show_somnium_post_install_guidance(self):
"""Show guidance popup for Somnium post-installation steps"""
from ..services.message_service import MessageService
guidance_text = f"""<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()
self.collapse_show_details_before_leave()
self.go_back()
def reset_screen_to_defaults(self):
"""Reset the screen to default state when navigating back from main menu"""
# Reset form fields
self.modlist_btn.setText("Select Modlist")
self.modlist_btn.setEnabled(False)
self.file_edit.setText("")
self.modlist_name_edit.setText("")
self.install_dir_edit.setText(self.config_handler.get_modlist_install_base_dir())
# Reset game type button
self.game_type_btn.setText("Please Select...")
# Clear console and process monitor
self.console.clear()
self.process_monitor.clear()
# Reset tabs to first tab (Online)
self.source_tabs.setCurrentIndex(0)
# Reset resolution combo to saved config preference
saved_resolution = self.resolution_service.get_saved_resolution()
if saved_resolution:
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
self.resolution_combo.setCurrentIndex(resolution_index)
elif self.resolution_combo.count() > 0:
self.resolution_combo.setCurrentIndex(0) # Fallback to "Leave unchanged"
# Re-enable controls (in case they were disabled from previous errors)
self._enable_controls_after_operation()