mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 00:17:58 +02:00
411 lines
18 KiB
Python
411 lines
18 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, _get_sidebar_urls
|
|
from ..widgets.unsupported_game_dialog import UnsupportedGameDialog
|
|
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
|
|
from jackify.backend.handlers.wabbajack_parser import WabbajackParser
|
|
import traceback
|
|
from jackify.backend.core.modlist_operations import get_jackify_engine_path
|
|
import signal
|
|
import re
|
|
import time
|
|
from jackify.backend.handlers.subprocess_utils import ProcessManager
|
|
from jackify.backend.handlers.config_handler import ConfigHandler
|
|
from ..dialogs import SuccessDialog
|
|
from jackify.backend.handlers.validation_handler import ValidationHandler
|
|
from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog
|
|
from jackify.frontends.gui.services.message_service import MessageService
|
|
from jackify.shared.errors import manual_steps_incomplete
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
from .install_ttw_ui_setup import TTWUISetupMixin
|
|
from .install_ttw_integration import TTWIntegrationMixin
|
|
from .install_ttw_requirements import TTWRequirementsMixin
|
|
from .install_ttw_lifecycle import TTWLifecycleMixin
|
|
from .install_ttw_workflow import TTWWorkflowMixin
|
|
from .install_ttw_output import TTWOutputMixin
|
|
from .install_ttw_ui import TTWUIMixin
|
|
from .screen_back_mixin import ScreenBackMixin
|
|
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
|
|
|
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(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TTWRequirementsMixin, TTWLifecycleMixin, QWidget, TTWWorkflowMixin, TTWOutputMixin, TTWUIMixin):
|
|
resize_request = Signal(str)
|
|
integration_complete = Signal(bool, str) # Signal for modlist integration completion (success, ttw_version)
|
|
|
|
def _collect_actionable_controls(self):
|
|
"""Collect all actionable controls that should be disabled during operations (except Cancel)"""
|
|
self._actionable_controls = [
|
|
# Main action button
|
|
self.start_btn,
|
|
# File selection
|
|
self.file_edit,
|
|
self.file_btn,
|
|
# Install directory
|
|
self.install_dir_edit,
|
|
self.browse_install_btn,
|
|
]
|
|
|
|
def _disable_controls_during_operation(self):
|
|
"""Disable all actionable controls during install/configure operations (except Cancel)"""
|
|
for control in self._actionable_controls:
|
|
if control:
|
|
control.setEnabled(False)
|
|
|
|
def _enable_controls_after_operation(self):
|
|
"""Re-enable all actionable controls after install/configure operations complete"""
|
|
for control in self._actionable_controls:
|
|
if control:
|
|
control.setEnabled(True)
|
|
|
|
def refresh_paths(self):
|
|
"""Refresh cached paths when config changes."""
|
|
from jackify.shared.paths import get_jackify_logs_dir
|
|
self.modlist_log_path = get_jackify_logs_dir() / 'TTW_Install_workflow.log'
|
|
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
|
|
|
|
def _open_url_safe(self, url):
|
|
"""Safely open URL via subprocess to avoid Qt library clashes inside the AppImage runtime"""
|
|
import subprocess
|
|
try:
|
|
subprocess.Popen(['xdg-open', url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
except Exception as e:
|
|
print(f"Warning: Could not open URL {url}: {e}")
|
|
|
|
def _load_saved_parent_directories(self):
|
|
"""No-op: do not pre-populate install/download directories from saved values."""
|
|
pass
|
|
|
|
def _update_directory_suggestions(self, modlist_name):
|
|
"""Update directory suggestions based on modlist name"""
|
|
try:
|
|
if not modlist_name:
|
|
return
|
|
|
|
# Update install directory suggestion with modlist name
|
|
saved_install_parent = self.config_handler.get_default_install_parent_dir()
|
|
if saved_install_parent:
|
|
suggested_install_dir = os.path.join(saved_install_parent, modlist_name)
|
|
self.install_dir_edit.setText(suggested_install_dir)
|
|
logger.debug(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")
|
|
logger.debug(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}")
|
|
|
|
except Exception as e:
|
|
logger.debug(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):
|
|
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)
|
|
dialog.setSidebarUrls(_get_sidebar_urls())
|
|
if dialog.exec() == QDialog.Accepted:
|
|
files = dialog.selectedFiles()
|
|
if files:
|
|
self.file_edit.setText(files[0])
|
|
|
|
def browse_install_dir(self):
|
|
dialog = QFileDialog(self, "Select Install Directory")
|
|
dialog.setFileMode(QFileDialog.Directory)
|
|
dialog.setOption(QFileDialog.ShowDirsOnly, True)
|
|
dialog.setOption(QFileDialog.DontUseNativeDialog, True)
|
|
dialog.setSidebarUrls(_get_sidebar_urls())
|
|
if self.install_dir_edit.text():
|
|
dialog.setDirectory(self.install_dir_edit.text())
|
|
if dialog.exec() == QDialog.Accepted:
|
|
dirs = dialog.selectedFiles()
|
|
if dirs:
|
|
self.install_dir_edit.setText(dirs[0])
|
|
|
|
def update_top_panel(self):
|
|
try:
|
|
result = subprocess.run([
|
|
"ps", "-eo", "pcpu,pmem,comm,args"
|
|
], stdout=subprocess.PIPE, text=True, timeout=2)
|
|
lines = result.stdout.splitlines()
|
|
header = "CPU%\tMEM%\tCOMMAND"
|
|
filtered = [header]
|
|
process_rows = []
|
|
for line in lines[1:]:
|
|
line_lower = line.lower()
|
|
if (
|
|
("jackify-engine" in line_lower or "7zz" in line_lower or "texconv" in line_lower or
|
|
"wine" in line_lower or "wine64" in line_lower or "protontricks" in line_lower or
|
|
"ttw_linux" in line_lower)
|
|
and "jackify-gui.py" not in line_lower
|
|
):
|
|
cols = line.strip().split(None, 3)
|
|
if len(cols) >= 3:
|
|
process_rows.append(cols)
|
|
process_rows.sort(key=lambda x: float(x[0]), reverse=True)
|
|
for cols in process_rows:
|
|
filtered.append('\t'.join(cols))
|
|
if len(filtered) == 1:
|
|
filtered.append("[No Jackify-related processes found]")
|
|
self.process_monitor.setPlainText('\n'.join(filtered))
|
|
except Exception as e:
|
|
self.process_monitor.setPlainText(f"[process info unavailable: {e}]")
|
|
|
|
def _check_protontricks(self):
|
|
"""Check if protontricks is available before critical operations"""
|
|
try:
|
|
if self.protontricks_service.is_bundled_mode():
|
|
return True
|
|
|
|
is_installed, installation_type, details = self.protontricks_service.detect_protontricks()
|
|
|
|
if not is_installed:
|
|
# Show protontricks error dialog
|
|
from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog
|
|
dialog = ProtontricksErrorDialog(self.protontricks_service, self)
|
|
result = dialog.exec()
|
|
|
|
if result == QDialog.Rejected:
|
|
return False
|
|
|
|
# Re-check after dialog
|
|
is_installed, _, _ = self.protontricks_service.detect_protontricks(use_cache=False)
|
|
return is_installed
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"Error checking protontricks: {e}")
|
|
MessageService.warning(self, "Protontricks Check Failed",
|
|
f"Unable to verify protontricks installation: {e}\n\n"
|
|
"Continuing anyway, but some features may not work correctly.")
|
|
return True # Continue anyway
|
|
|
|
def _write_to_log_file(self, message):
|
|
"""Write message to workflow log file with timestamp."""
|
|
try:
|
|
import re
|
|
from datetime import datetime
|
|
clean = re.sub(r'<[^>]+>', '', str(message))
|
|
if not clean.strip():
|
|
return
|
|
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
with open(self.modlist_log_path, 'a', encoding='utf-8') as f:
|
|
for line in clean.splitlines():
|
|
stripped = line.rstrip()
|
|
if stripped:
|
|
f.write(f"[{timestamp}] {stripped}\n")
|
|
except Exception:
|
|
pass
|
|
|
|
def handle_validation_failure(self, missing_text):
|
|
"""Handle failed validation with retry logic"""
|
|
self._manual_steps_retry_count += 1
|
|
|
|
if self._manual_steps_retry_count < 3:
|
|
# Show retry dialog with increasingly detailed guidance
|
|
retry_guidance = ""
|
|
if self._manual_steps_retry_count == 1:
|
|
retry_guidance = "\n\nTip: Make sure Steam is fully restarted before trying again."
|
|
elif self._manual_steps_retry_count == 2:
|
|
retry_guidance = "\n\nTip: If using Flatpak Steam, ensure compatdata is being created in the correct location."
|
|
|
|
MessageService.show_error(self, manual_steps_incomplete())
|
|
# 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.show_error(self, manual_steps_incomplete())
|
|
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"""
|
|
# Disconnect all signals first - prevents callbacks to a dying widget.
|
|
self._park_all_threads()
|
|
|
|
# install_thread gets a cooperative cancel signal on top of the park.
|
|
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
|
|
try:
|
|
self.install_thread.cancel()
|
|
except Exception:
|
|
pass
|
|
|
|
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
|
|
safety_level="medium",
|
|
)
|
|
|
|
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;
|
|
""")
|
|
|
|
# Park all threads first (disconnects signals), then send cooperative cancel.
|
|
self._park_all_threads()
|
|
if hasattr(self, 'install_thread') and self.install_thread:
|
|
try:
|
|
self.install_thread.cancel()
|
|
except Exception:
|
|
pass
|
|
|
|
# Cleanup any remaining processes
|
|
self.cleanup_processes()
|
|
|
|
# Reset button states and re-enable all controls
|
|
self._enable_controls_after_operation()
|
|
self.cancel_btn.setVisible(True)
|
|
self.cancel_install_btn.setVisible(False)
|
|
|
|
# Collapse window if "Show Details" is checked
|
|
if hasattr(self, 'show_details_checkbox') and self.show_details_checkbox.isChecked():
|
|
self.resize_request.emit('collapse')
|
|
self.show_details_checkbox.blockSignals(True)
|
|
self.show_details_checkbox.setChecked(False)
|
|
self.show_details_checkbox.blockSignals(False)
|
|
|
|
self._safe_append_text("Installation cancelled by user.")
|
|
|
|
def _show_somnium_post_install_guidance(self):
|
|
"""Show guidance popup for Somnium post-installation steps"""
|
|
from ..services.message_service import MessageService
|
|
|
|
guidance_text = f"""<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.collapse_show_details_before_leave()
|
|
self.go_back()
|
|
QTimer.singleShot(0, self.cleanup_processes)
|
|
|
|
def reset_screen_to_defaults(self):
|
|
"""Reset the screen to default state when navigating back from main menu"""
|
|
if not getattr(self, '_integration_mode', False):
|
|
# Reset form fields only when not pre-populated by a caller
|
|
self.file_edit.setText("")
|
|
self.install_dir_edit.setText(self.config_handler.get_modlist_install_base_dir())
|
|
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()
|
|
|
|
|