Files
Jackify/jackify/frontends/gui/screens/install_mo2_screen.py
2026-02-25 20:54:28 +00:00

455 lines
18 KiB
Python

"""
Install MO2 Screen
Downloads and configures a standalone Mod Organizer 2 instance via
MO2SetupService. No Wabbajack modlist required.
"""
import logging
import os
from pathlib import Path
from typing import Optional
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QFileDialog, QLineEdit, QGridLayout, QTextEdit, QCheckBox,
QMessageBox, QSizePolicy,
)
from PySide6.QtCore import Qt, QThread, Signal, QSize
from jackify.backend.models.configuration import SystemInfo
from jackify.shared.errors import mo2_setup_failed
from jackify.shared.progress_models import FileProgress, OperationType
from ..services.message_service import MessageService
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from ..utils import set_responsive_minimum
from ..widgets.progress_indicator import OverallProgressIndicator
from ..widgets.file_progress_list import FileProgressList
from .screen_back_mixin import ScreenBackMixin
logger = logging.getLogger(__name__)
class MO2SetupWorker(QThread):
"""Background worker for standalone MO2 setup"""
progress_update = Signal(str)
log_output = Signal(str)
setup_complete = Signal(bool, object, str) # success, app_id (int|None), error_msg
def __init__(self, install_dir: Path, shortcut_name: str):
super().__init__()
self.install_dir = install_dir
self.shortcut_name = shortcut_name
def run(self):
from jackify.backend.services.mo2_setup_service import MO2SetupService
def _progress(msg: str):
if self.isInterruptionRequested():
return
self.progress_update.emit(msg)
self.log_output.emit(msg)
try:
service = MO2SetupService()
success, app_id, error_msg = service.setup_mo2(
install_dir=self.install_dir,
shortcut_name=self.shortcut_name,
progress_callback=_progress,
should_cancel=self.isInterruptionRequested,
)
if self.isInterruptionRequested():
self.setup_complete.emit(False, None, "MO2 setup cancelled.")
return
self.setup_complete.emit(success, app_id, error_msg or "")
except Exception as e:
logger.exception("Unhandled exception in MO2 setup worker")
self.setup_complete.emit(False, None, str(e))
class InstallMO2Screen(ScreenBackMixin, QWidget):
"""Standalone MO2 setup screen"""
resize_request = Signal(str)
def __init__(
self,
stacked_widget=None,
additional_tasks_index: int = 3,
system_info: Optional[SystemInfo] = None,
):
super().__init__()
self.stacked_widget = stacked_widget
self.main_menu_index = additional_tasks_index
self.additional_tasks_index = additional_tasks_index
self.system_info = system_info or SystemInfo(is_steamdeck=False)
self.debug = DEBUG_BORDERS
self.worker = None
self._user_manually_scrolled = False
self._was_at_bottom = True
self.progress_indicator = OverallProgressIndicator(show_progress_bar=False)
self.progress_indicator.set_status("Ready", 0)
self.file_progress_list = FileProgressList()
self._setup_ui()
def _setup_ui(self):
main_layout = QVBoxLayout(self)
main_layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
main_layout.setContentsMargins(50, 50, 50, 0)
main_layout.setSpacing(12)
self._setup_header(main_layout)
self._setup_upper_section(main_layout)
self._setup_status_banner(main_layout)
self._setup_console(main_layout)
self._setup_buttons(main_layout)
def _setup_header(self, layout):
header_layout = QVBoxLayout()
header_layout.setSpacing(1)
title = QLabel("<b>Setup Mod Organizer 2</b>")
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;")
title.setAlignment(Qt.AlignHCenter)
title.setMaximumHeight(30)
header_layout.addWidget(title)
desc = QLabel("Download and configure a standalone MO2 instance with a Proton prefix")
desc.setWordWrap(True)
desc.setStyleSheet("color: #ccc; margin: 0px; padding: 0px;")
desc.setAlignment(Qt.AlignHCenter)
desc.setMaximumHeight(40)
header_layout.addWidget(desc)
header_widget = QWidget()
header_widget.setLayout(header_layout)
header_widget.setMaximumHeight(75)
layout.addWidget(header_widget)
def _setup_upper_section(self, layout):
upper_hbox = QHBoxLayout()
upper_hbox.setContentsMargins(0, 0, 0, 0)
upper_hbox.setSpacing(16)
# Left: form
form_widget = self._build_form_widget()
upper_hbox.addWidget(form_widget, stretch=11)
# Right: activity window
activity_header = QLabel("<b>[Activity]</b>")
activity_header.setStyleSheet(
f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;"
)
activity_header.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
self.file_progress_list.setMinimumSize(QSize(300, 20))
self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
activity_vbox = QVBoxLayout()
activity_vbox.setContentsMargins(0, 0, 0, 0)
activity_vbox.setSpacing(2)
activity_vbox.addWidget(activity_header)
activity_vbox.addWidget(self.file_progress_list, stretch=1)
activity_widget = QWidget()
activity_widget.setLayout(activity_vbox)
activity_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
upper_hbox.addWidget(activity_widget, stretch=9)
upper_section = QWidget()
upper_section.setLayout(upper_hbox)
upper_section.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
upper_section.setMaximumHeight(240)
layout.addWidget(upper_section)
def _build_form_widget(self):
form_vbox = QVBoxLayout()
form_vbox.setAlignment(Qt.AlignTop)
form_vbox.setContentsMargins(0, 0, 0, 0)
form_vbox.setSpacing(8)
options_header = QLabel("<b>[Options]</b>")
options_header.setStyleSheet(
f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; font-weight: bold;"
)
options_header.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
form_vbox.addWidget(options_header)
form_grid = QGridLayout()
form_grid.setHorizontalSpacing(12)
form_grid.setVerticalSpacing(8)
form_grid.setContentsMargins(0, 0, 0, 0)
# Shortcut name
form_grid.addWidget(QLabel("Shortcut Name:"), 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
self.shortcut_name_edit = QLineEdit("Mod Organizer 2")
self.shortcut_name_edit.setMaximumHeight(25)
form_grid.addWidget(self.shortcut_name_edit, 0, 1)
# Install directory
form_grid.addWidget(QLabel("Install Directory:"), 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
default_dir = str(Path.home() / "ModOrganizer2")
self.install_dir_edit = QLineEdit(default_dir)
self.install_dir_edit.setMaximumHeight(25)
browse_btn = QPushButton("Browse")
browse_btn.setFixedSize(80, 25)
browse_btn.clicked.connect(self._browse_folder)
dir_hbox = QHBoxLayout()
dir_hbox.addWidget(self.install_dir_edit)
dir_hbox.addWidget(browse_btn)
form_grid.addLayout(dir_hbox, 1, 1)
form_vbox.addLayout(form_grid)
info = QLabel(
"Jackify will download the latest Mod Organizer 2 release from GitHub, extract it to the "
"chosen directory, add it as a non-Steam game, and configure a Proton prefix automatically. "
"Steam will be restarted during this process."
)
info.setWordWrap(True)
info.setStyleSheet("color: #999; font-size: 11px;")
form_vbox.addWidget(info)
form_widget = QWidget()
form_widget.setLayout(form_vbox)
form_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
return form_widget
def _setup_status_banner(self, layout):
banner_row = QHBoxLayout()
banner_row.setContentsMargins(0, 0, 0, 0)
banner_row.setSpacing(8)
banner_row.addWidget(self.progress_indicator, 1)
banner_row.addStretch()
self.show_details_checkbox = QCheckBox("Show details")
self.show_details_checkbox.setChecked(False)
self.show_details_checkbox.toggled.connect(self._on_show_details_toggled)
banner_row.addWidget(self.show_details_checkbox)
banner_widget = QWidget()
banner_widget.setLayout(banner_row)
banner_widget.setMaximumHeight(45)
banner_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
layout.addWidget(banner_widget)
def _setup_console(self, layout):
self.console = QTextEdit()
self.console.setReadOnly(True)
self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.console.setMinimumHeight(50)
self.console.setMaximumHeight(1000)
self.console.setFontFamily('monospace')
self.console.setVisible(False)
scrollbar = self.console.verticalScrollBar()
scrollbar.sliderPressed.connect(lambda: setattr(self, '_user_manually_scrolled', True))
scrollbar.sliderReleased.connect(lambda: setattr(self, '_user_manually_scrolled', False))
scrollbar.valueChanged.connect(self._on_scrollbar_value_changed)
layout.addWidget(self.console, stretch=1)
def _on_scrollbar_value_changed(self):
scrollbar = self.console.verticalScrollBar()
self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1
def _setup_buttons(self, layout):
btn_row = QHBoxLayout()
btn_row.setAlignment(Qt.AlignHCenter)
self.start_btn = QPushButton("Start Setup")
self.start_btn.setFixedHeight(35)
self.start_btn.clicked.connect(self._start_setup)
btn_row.addWidget(self.start_btn)
self.cancel_btn = QPushButton("Cancel")
self.cancel_btn.setFixedHeight(35)
self.cancel_btn.clicked.connect(self._go_back)
btn_row.addWidget(self.cancel_btn)
btn_widget = QWidget()
btn_widget.setLayout(btn_row)
btn_widget.setMaximumHeight(50)
layout.addWidget(btn_widget)
def _on_show_details_toggled(self, checked):
self.console.setVisible(checked)
self.resize_request.emit("expand" if checked else "collapse")
def _browse_folder(self):
folder = QFileDialog.getExistingDirectory(
self, "Select MO2 Installation Folder", str(Path.home()), QFileDialog.ShowDirsOnly
)
if folder:
self.install_dir_edit.setText(os.path.realpath(folder))
# ------------------------------------------------------------------
# Activity window helpers
# ------------------------------------------------------------------
# Maps a substring of the progress message to (item_id, display_label, OperationType, percent)
_ACTIVITY_MAP = [
("Fetching latest MO2", "fetch", "Fetching release info", OperationType.UNKNOWN, 0.0),
("Downloading ", "download", "Downloading MO2 archive", OperationType.DOWNLOAD, 0.0),
("Extracting to ", "extract", "Extracting archive", OperationType.EXTRACT, 0.0),
("MO2 installed at", "extract", "Extracting archive", OperationType.EXTRACT, 100.0),
("Creating Steam shortcut", "prefix", "Creating Steam shortcut & prefix", OperationType.INSTALL, 0.0),
("MO2 setup complete", "complete", "Setup complete", OperationType.INSTALL, 100.0),
]
def _on_activity_progress(self, message: str):
for trigger, item_id, label, op_type, pct in self._ACTIVITY_MAP:
if trigger in message:
fp = FileProgress(
filename=label,
operation=op_type,
percent=pct,
current_size=0,
total_size=0,
)
self.file_progress_list.update_files([fp])
break
# ------------------------------------------------------------------
def _start_setup(self):
install_dir_text = self.install_dir_edit.text().strip()
if not install_dir_text:
MessageService.warning(self, "No Directory", "Please select an installation directory.")
return
install_dir = Path(install_dir_text).resolve()
shortcut_name = self.shortcut_name_edit.text().strip() or "Mod Organizer 2"
confirm = MessageService.question(
self,
"Confirm MO2 Setup",
f"Install Mod Organizer 2 to:\n{install_dir}\n\n"
"Jackify will download MO2, add it to Steam, and configure a Proton prefix.\n"
"Steam will be restarted during this process.\n\nContinue?",
safety_level="medium",
)
if confirm != QMessageBox.Yes:
return
self.console.clear()
self.file_progress_list.clear()
self.file_progress_list.start_cpu_tracking()
self.start_btn.setEnabled(False)
self.cancel_btn.setEnabled(False)
self.progress_indicator.set_status("Starting...", 0)
self.worker = MO2SetupWorker(install_dir, shortcut_name)
self.worker.progress_update.connect(self._on_progress_update)
self.worker.progress_update.connect(self._on_activity_progress)
self.worker.log_output.connect(self._on_log_output)
self.worker.setup_complete.connect(self._on_setup_complete)
self.worker.start()
def _on_progress_update(self, message: str):
self.progress_indicator.set_status(message, 0)
def _on_log_output(self, message: str):
scrollbar = self.console.verticalScrollBar()
was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1
self.console.append(message)
if was_at_bottom and not self._user_manually_scrolled:
scrollbar.setValue(scrollbar.maximum())
def _on_setup_complete(self, success: bool, app_id, error_msg: str):
self.file_progress_list.stop_cpu_tracking()
if success:
self.progress_indicator.set_status("Setup complete!", 100)
MessageService.information(
self,
"MO2 Setup Complete",
f"Mod Organizer 2 has been installed and configured.\n\n"
f"Steam AppID: {app_id}\n\n"
"Launch MO2 from your Steam library.",
)
self.install_dir_edit.setText(str(Path.home() / "ModOrganizer2"))
self.shortcut_name_edit.setText("Mod Organizer 2")
else:
self.progress_indicator.set_status("Setup failed", 0)
MessageService.show_error(self, mo2_setup_failed(error_msg or "Setup failed."))
self.start_btn.setEnabled(True)
self.cancel_btn.setEnabled(True)
if self.worker is not None:
try:
self.worker.deleteLater()
except Exception:
pass
self.worker = None
def _go_back(self):
if self.worker and self.worker.isRunning():
reply = MessageService.question(
self,
"MO2 Setup In Progress",
"MO2 setup is still running. Leave this screen and cancel setup?",
critical=False,
safety_level="medium",
)
if reply != QMessageBox.Yes:
return
self.cleanup_processes()
self.collapse_show_details_before_leave()
self.go_back()
def cleanup_processes(self):
"""Stop active MO2 worker and CPU tracking before screen/app shutdown."""
try:
self.file_progress_list.stop_cpu_tracking()
except Exception:
pass
if self.worker is not None:
try:
if self.worker.isRunning():
self.worker.requestInterruption()
if not self.worker.wait(5000):
self.worker.terminate()
self.worker.wait(10000)
self.worker.deleteLater()
except Exception:
pass
self.worker = None
def reset_screen_to_defaults(self):
self.file_progress_list.clear()
self.console.clear()
self.progress_indicator.set_status("Ready", 0)
if self.show_details_checkbox.isChecked():
self.show_details_checkbox.blockSignals(True)
self.show_details_checkbox.setChecked(False)
self.show_details_checkbox.blockSignals(False)
self.console.setVisible(False)
self.resize_request.emit("collapse")
def showEvent(self, event):
super().showEvent(event)
# Keep MO2 screen consistent with other workflows: details collapsed by default.
if self.show_details_checkbox.isChecked():
self.show_details_checkbox.blockSignals(True)
self.show_details_checkbox.setChecked(False)
self.show_details_checkbox.blockSignals(False)
self.console.setVisible(False)
self.resize_request.emit("collapse")
try:
main_window = self.window()
if main_window:
main_window.setMaximumSize(QSize(16777215, 16777215))
set_responsive_minimum(main_window, min_width=960, min_height=420)
except Exception:
pass