mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 01:47:45 +02:00
Sync from development - prepare for v0.5.0
This commit is contained in:
204
jackify/frontends/gui/dialogs/existing_setup_dialog.py
Normal file
204
jackify/frontends/gui/dialogs/existing_setup_dialog.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""Shared dialog for existing install/shortcut detection decisions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
||||
def prompt_existing_setup_dialog(
|
||||
parent: QWidget,
|
||||
*,
|
||||
window_title: str,
|
||||
heading: str,
|
||||
body: str,
|
||||
existing_name: str,
|
||||
requested_name: str,
|
||||
install_dir: Optional[str] = None,
|
||||
field_label: str = "New shortcut name",
|
||||
reuse_label: str = "Use Existing Setup",
|
||||
new_label: str = "Create New Shortcut",
|
||||
cancel_label: str = "Cancel",
|
||||
) -> Tuple[str, Optional[str]]:
|
||||
"""
|
||||
Show the shared existing-setup dialog.
|
||||
|
||||
Returns:
|
||||
("reuse"|"new"|"cancel", new_name_or_none)
|
||||
"""
|
||||
dialog = QDialog(parent)
|
||||
dialog.setWindowTitle(window_title)
|
||||
dialog.setModal(True)
|
||||
dialog.setMinimumWidth(760)
|
||||
dialog.setMinimumHeight(320)
|
||||
|
||||
dialog.setStyleSheet(
|
||||
"""
|
||||
QDialog {
|
||||
background: #181818;
|
||||
color: #ffffff;
|
||||
border-radius: 12px;
|
||||
}
|
||||
QFrame#dialogCard {
|
||||
background: #23272e;
|
||||
border: 1px solid #353a40;
|
||||
border-radius: 12px;
|
||||
}
|
||||
QFrame#infoCard {
|
||||
background: #2a2f36;
|
||||
border: 1px solid #3b4148;
|
||||
border-radius: 8px;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
padding: 0px;
|
||||
}
|
||||
QLabel#dialogTitle {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #3fb7d6;
|
||||
}
|
||||
QLabel#dialogBody {
|
||||
color: #e0e0e0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
QLabel#infoLabel {
|
||||
color: #c7d0d8;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
QLabel#fieldLabel {
|
||||
color: #b0b0b0;
|
||||
font-size: 12px;
|
||||
}
|
||||
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;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
outer_layout = QVBoxLayout(dialog)
|
||||
outer_layout.setContentsMargins(24, 20, 24, 20)
|
||||
outer_layout.setSpacing(0)
|
||||
|
||||
card = QFrame(dialog)
|
||||
card.setObjectName("dialogCard")
|
||||
card_layout = QVBoxLayout(card)
|
||||
card_layout.setContentsMargins(22, 22, 22, 22)
|
||||
card_layout.setSpacing(14)
|
||||
|
||||
title_label = QLabel(heading)
|
||||
title_label.setObjectName("dialogTitle")
|
||||
title_label.setAlignment(Qt.AlignCenter)
|
||||
title_label.setWordWrap(True)
|
||||
card_layout.addWidget(title_label)
|
||||
|
||||
body_label = QLabel(body)
|
||||
body_label.setObjectName("dialogBody")
|
||||
body_label.setAlignment(Qt.AlignCenter)
|
||||
body_label.setWordWrap(True)
|
||||
card_layout.addWidget(body_label)
|
||||
|
||||
info_card = QFrame(card)
|
||||
info_card.setObjectName("infoCard")
|
||||
info_layout = QVBoxLayout(info_card)
|
||||
info_layout.setContentsMargins(14, 12, 14, 12)
|
||||
info_layout.setSpacing(6)
|
||||
|
||||
info_lines = [
|
||||
f"<b>Existing shortcut:</b> {existing_name}",
|
||||
f"<b>Requested name:</b> {requested_name or existing_name}",
|
||||
]
|
||||
if install_dir:
|
||||
info_lines.append(f"<b>Install directory:</b> {install_dir}")
|
||||
info_label = QLabel("<br>".join(info_lines))
|
||||
info_label.setObjectName("infoLabel")
|
||||
info_label.setTextFormat(Qt.RichText)
|
||||
info_label.setWordWrap(True)
|
||||
info_layout.addWidget(info_label)
|
||||
card_layout.addWidget(info_card)
|
||||
|
||||
field_title = QLabel(field_label)
|
||||
field_title.setObjectName("fieldLabel")
|
||||
card_layout.addWidget(field_title)
|
||||
|
||||
name_input = QLineEdit(requested_name or existing_name)
|
||||
name_input.selectAll()
|
||||
card_layout.addWidget(name_input)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setSpacing(10)
|
||||
|
||||
reuse_button = QPushButton(reuse_label)
|
||||
cancel_button = QPushButton(cancel_label)
|
||||
new_button = QPushButton(new_label)
|
||||
for button in (reuse_button, cancel_button, new_button):
|
||||
button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
|
||||
button_layout.addWidget(reuse_button)
|
||||
button_layout.addWidget(cancel_button)
|
||||
button_layout.addWidget(new_button)
|
||||
card_layout.addLayout(button_layout)
|
||||
outer_layout.addWidget(card)
|
||||
|
||||
result = {"action": "cancel", "new_name": None}
|
||||
|
||||
def on_reuse():
|
||||
result["action"] = "reuse"
|
||||
dialog.accept()
|
||||
|
||||
def on_new():
|
||||
result["action"] = "new"
|
||||
result["new_name"] = name_input.text().strip()
|
||||
dialog.accept()
|
||||
|
||||
def on_cancel():
|
||||
result["action"] = "cancel"
|
||||
dialog.reject()
|
||||
|
||||
reuse_button.clicked.connect(on_reuse)
|
||||
new_button.clicked.connect(on_new)
|
||||
cancel_button.clicked.connect(on_cancel)
|
||||
name_input.returnPressed.connect(on_new)
|
||||
|
||||
dialog.adjustSize()
|
||||
dialog.exec()
|
||||
return result["action"], result["new_name"]
|
||||
466
jackify/frontends/gui/dialogs/manual_download_dialog.py
Normal file
466
jackify/frontends/gui/dialogs/manual_download_dialog.py
Normal file
@@ -0,0 +1,466 @@
|
||||
"""
|
||||
Manual Download Dialog
|
||||
|
||||
Shown when the engine requires manual downloads (non-premium or forced-manual
|
||||
archives). Displays all pending items in a scrollable table, manages browser
|
||||
tab concurrency, and coordinates with ManualDownloadManager.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt, Signal, QObject
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QSpinBox, QFrame, QTableWidget, QTableWidgetItem, QHeaderView,
|
||||
QProgressBar, QFileDialog, QSizePolicy,
|
||||
)
|
||||
from PySide6.QtGui import QFont
|
||||
|
||||
from jackify.backend.services.manual_download_manager import ManualDownloadManager, DownloadItem
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_STATUS_LABELS = {
|
||||
'pending': 'Pending',
|
||||
'browser_opened': 'Browser Opened',
|
||||
'validating': 'Validating...',
|
||||
'complete': 'Complete',
|
||||
'deferred': 'Deferred',
|
||||
'skipped': 'Skipped',
|
||||
'error': 'Error',
|
||||
}
|
||||
|
||||
_STATUS_COLOURS = {
|
||||
'pending': '#808080',
|
||||
'browser_opened': '#3498db',
|
||||
'validating': '#f39c12',
|
||||
'complete': '#27ae60',
|
||||
'deferred': '#e67e22',
|
||||
'skipped': '#e67e22',
|
||||
'error': '#e74c3c',
|
||||
}
|
||||
|
||||
# Column indices
|
||||
_COL_MOD = 0
|
||||
_COL_NAME = 1
|
||||
_COL_SIZE = 2
|
||||
_COL_STATUS = 3
|
||||
|
||||
|
||||
def _fmt_size(n: int) -> str:
|
||||
if n <= 0:
|
||||
return ''
|
||||
for unit in ('B', 'KB', 'MB', 'GB'):
|
||||
if n < 1024:
|
||||
return f"{n:.0f} {unit}"
|
||||
n /= 1024
|
||||
return f"{n:.1f} TB"
|
||||
|
||||
|
||||
class _Bridge(QObject):
|
||||
"""Tiny bridge so worker-thread callbacks can update the Qt table safely."""
|
||||
item_updated = Signal(object) # DownloadItem
|
||||
all_done = Signal(int, int) # completed, skipped
|
||||
|
||||
|
||||
class ManualDownloadDialog(QDialog):
|
||||
"""
|
||||
Displays all pending manual downloads and coordinates the download workflow.
|
||||
Non-modal so the install log remains visible.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manager: ManualDownloadManager,
|
||||
modlist_name: str = '',
|
||||
watch_directory: Optional[Path] = None,
|
||||
concurrent_limit: int = 2,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._manager = manager
|
||||
self._modlist_name = modlist_name
|
||||
self._watch_dir = watch_directory or (Path.home() / 'Downloads')
|
||||
self._paused = False
|
||||
self._started = False
|
||||
self._initial_concurrent_limit = max(1, min(5, int(concurrent_limit)))
|
||||
|
||||
# Row index by file_name for fast updates
|
||||
self._row_map: dict[str, int] = {}
|
||||
|
||||
# Bridge for thread-safe table updates
|
||||
self._bridge = _Bridge()
|
||||
self._bridge.item_updated.connect(self._on_item_updated_slot)
|
||||
self._bridge.all_done.connect(self._on_all_done_slot)
|
||||
|
||||
# Preserve any existing manager callbacks so workflow controllers still
|
||||
# receive completion events after the dialog updates its own UI.
|
||||
prev_item_updated = self._manager._on_item_updated
|
||||
prev_all_done = self._manager._on_all_done
|
||||
|
||||
def _emit_item_updated(item):
|
||||
self._bridge.item_updated.emit(item)
|
||||
if prev_item_updated:
|
||||
prev_item_updated(item)
|
||||
|
||||
def _emit_all_done(completed: int, skipped: int):
|
||||
self._bridge.all_done.emit(completed, skipped)
|
||||
if prev_all_done:
|
||||
prev_all_done(completed, skipped)
|
||||
|
||||
self._manager._on_item_updated = _emit_item_updated
|
||||
self._manager._on_all_done = _emit_all_done
|
||||
|
||||
self.setWindowTitle("Manual Downloads Required")
|
||||
self.setMinimumSize(760, 500)
|
||||
self.setModal(False)
|
||||
self._build_ui()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def showEvent(self, event) -> None:
|
||||
super().showEvent(event)
|
||||
if not self._started:
|
||||
# Keep the workflow idle until the user explicitly clicks Start.
|
||||
# Start backend services in paused mode so watcher/precheck are ready
|
||||
# without opening browser tabs yet.
|
||||
self._paused = False
|
||||
self._manager.pause()
|
||||
self._manager.start()
|
||||
self._start_pause_btn.setText("Start")
|
||||
self._progress_label.setText("Ready - click Start to begin opening download tabs")
|
||||
|
||||
def load_items(self, items: list[DownloadItem]) -> None:
|
||||
"""
|
||||
Populate or refresh the table from a list of DownloadItems.
|
||||
On subsequent loop iterations the manager passes its full item list
|
||||
(including previously-completed rows), so we update existing rows and
|
||||
append only genuinely new ones rather than rebuilding the table.
|
||||
"""
|
||||
new_items = [i for i in items if i.file_name not in self._row_map]
|
||||
existing_items = [i for i in items if i.file_name in self._row_map]
|
||||
|
||||
# Update existing rows without disabling updates (usually few on repeat iterations)
|
||||
for item in existing_items:
|
||||
self._update_row(self._row_map[item.file_name], item)
|
||||
|
||||
# Batch-insert new rows with viewport updates suspended to avoid O(n²) repaints
|
||||
if new_items:
|
||||
self._table.setUpdatesEnabled(False)
|
||||
try:
|
||||
start_row = self._table.rowCount()
|
||||
self._table.setRowCount(start_row + len(new_items))
|
||||
for i, item in enumerate(new_items):
|
||||
self._fill_row(start_row + i, item)
|
||||
self._row_map[item.file_name] = start_row + i
|
||||
finally:
|
||||
self._table.setUpdatesEnabled(True)
|
||||
self._table.viewport().update()
|
||||
|
||||
self._refresh_header()
|
||||
# If user already started the workflow and engine enters another manual loop,
|
||||
# continue opening tabs for newly-pending items automatically.
|
||||
if self._started and not self._paused:
|
||||
self._manager.resume()
|
||||
|
||||
def update_item(self, item: DownloadItem) -> None:
|
||||
"""Called from any thread - bridges to Qt slot."""
|
||||
self._bridge.item_updated.emit(item)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Build UI
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(16, 16, 16, 16)
|
||||
root.setSpacing(10)
|
||||
|
||||
# Header
|
||||
hdr = QFrame()
|
||||
hdr.setFrameShape(QFrame.StyledPanel)
|
||||
hdr.setStyleSheet("QFrame { background: #1e2228; border-radius: 8px; border: 1px solid #333; }")
|
||||
hdr_layout = QVBoxLayout(hdr)
|
||||
hdr_layout.setContentsMargins(12, 10, 12, 10)
|
||||
hdr_layout.setSpacing(6)
|
||||
|
||||
self._title_label = QLabel(f"Modlist: {self._modlist_name or 'Unknown'}")
|
||||
self._title_label.setStyleSheet("color: #e0e0e0; font-size: 14px; font-weight: 600;")
|
||||
hdr_layout.addWidget(self._title_label)
|
||||
|
||||
self._progress_label = QLabel("Preparing...")
|
||||
self._progress_label.setStyleSheet("color: #aaaaaa; font-size: 12px;")
|
||||
hdr_layout.addWidget(self._progress_label)
|
||||
|
||||
self._progress_bar = QProgressBar()
|
||||
self._progress_bar.setRange(0, 100)
|
||||
self._progress_bar.setValue(0)
|
||||
self._progress_bar.setStyleSheet(
|
||||
f"QProgressBar {{ border: 1px solid #444; border-radius: 4px; background: #2c2c2c; "
|
||||
f"height: 12px; color: #d7e3f4; font-weight: 600; }}"
|
||||
f"QProgressBar::chunk {{ background: {JACKIFY_COLOR_BLUE}; border-radius: 3px; }}"
|
||||
)
|
||||
hdr_layout.addWidget(self._progress_bar)
|
||||
root.addWidget(hdr)
|
||||
|
||||
# Table
|
||||
self._table = QTableWidget()
|
||||
self._table.setColumnCount(4)
|
||||
self._table.setHorizontalHeaderLabels(['Mod', 'File', 'Size', 'Status'])
|
||||
self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
self._table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
||||
self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Fixed)
|
||||
self._table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Fixed)
|
||||
self._table.setColumnWidth(2, 90)
|
||||
self._table.setColumnWidth(3, 130)
|
||||
self._table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self._table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
self._table.setAlternatingRowColors(True)
|
||||
self._table.verticalHeader().setVisible(False)
|
||||
self._table.cellDoubleClicked.connect(self._on_row_double_clicked)
|
||||
self._table.setStyleSheet(
|
||||
"QTableWidget { background: #1a1d23; alternate-background-color: #1e2228; "
|
||||
"color: #d0d0d0; gridline-color: #333; border: 1px solid #333; border-radius: 4px; }"
|
||||
"QHeaderView::section { background: #252830; color: #aaa; border: none; "
|
||||
"padding: 4px; font-size: 11px; }"
|
||||
)
|
||||
root.addWidget(self._table)
|
||||
|
||||
# Controls row
|
||||
ctrl = QHBoxLayout()
|
||||
ctrl.setSpacing(12)
|
||||
|
||||
ctrl.addWidget(QLabel("Concurrent tabs:"))
|
||||
self._concurrent_spin = QSpinBox()
|
||||
self._concurrent_spin.setRange(1, 5)
|
||||
self._concurrent_spin.setValue(self._initial_concurrent_limit)
|
||||
self._concurrent_spin.setFixedWidth(60)
|
||||
self._concurrent_spin.valueChanged.connect(self._on_concurrent_changed)
|
||||
ctrl.addWidget(self._concurrent_spin)
|
||||
|
||||
ctrl.addSpacing(16)
|
||||
ctrl.addWidget(QLabel("Watch folder:"))
|
||||
self._folder_label = QLabel(str(self._watch_dir))
|
||||
self._folder_label.setStyleSheet("color: #aaa; font-size: 11px;")
|
||||
self._folder_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
ctrl.addWidget(self._folder_label)
|
||||
|
||||
folder_btn = QPushButton("...")
|
||||
folder_btn.setFixedSize(32, 28)
|
||||
folder_btn.clicked.connect(self._on_pick_folder)
|
||||
ctrl.addWidget(folder_btn)
|
||||
|
||||
root.addLayout(ctrl)
|
||||
|
||||
watch_hint = QLabel(
|
||||
"Jackify watches this folder for newly downloaded archives, validates them, "
|
||||
"then moves valid files into your modlist downloads folder automatically. "
|
||||
"Double-click a row (or use Open Selected) to reopen a URL if you closed a tab."
|
||||
)
|
||||
watch_hint.setWordWrap(True)
|
||||
watch_hint.setStyleSheet("color: #8f98a3; font-size: 11px;")
|
||||
root.addWidget(watch_hint)
|
||||
|
||||
# Action buttons
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.setSpacing(10)
|
||||
|
||||
self._retry_btn = QPushButton("Retry Deferred (0)")
|
||||
self._retry_btn.setEnabled(False)
|
||||
self._retry_btn.clicked.connect(self._on_retry_skipped)
|
||||
btn_row.addWidget(self._retry_btn)
|
||||
|
||||
self._defer_btn = QPushButton("Defer Selected")
|
||||
self._defer_btn.clicked.connect(self._on_defer_selected)
|
||||
btn_row.addWidget(self._defer_btn)
|
||||
|
||||
self._open_selected_btn = QPushButton("Open Selected")
|
||||
self._open_selected_btn.clicked.connect(self._on_open_selected)
|
||||
btn_row.addWidget(self._open_selected_btn)
|
||||
|
||||
btn_row.addStretch()
|
||||
|
||||
self._start_pause_btn = QPushButton("Start")
|
||||
self._start_pause_btn.clicked.connect(self._on_start_pause_clicked)
|
||||
btn_row.addWidget(self._start_pause_btn)
|
||||
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.setStyleSheet(
|
||||
"QPushButton { background: #7f2020; color: white; border: none; "
|
||||
"border-radius: 4px; padding: 6px 16px; }"
|
||||
"QPushButton:hover { background: #9b2828; }"
|
||||
)
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
|
||||
root.addLayout(btn_row)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Table helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _fill_row(self, row: int, item: DownloadItem) -> None:
|
||||
"""Populate cells for a pre-allocated row (row must already exist in the table)."""
|
||||
from PySide6.QtGui import QColor
|
||||
self._table.setItem(row, _COL_MOD, QTableWidgetItem(item.mod_name))
|
||||
self._table.setItem(row, _COL_NAME, QTableWidgetItem(item.file_name))
|
||||
self._table.setItem(row, _COL_SIZE, QTableWidgetItem(_fmt_size(item.expected_size)))
|
||||
colour = _STATUS_COLOURS.get(item.status, '#808080')
|
||||
status_cell = QTableWidgetItem(_STATUS_LABELS.get(item.status, item.status))
|
||||
status_cell.setForeground(QColor(colour))
|
||||
if item.error_message:
|
||||
status_cell.setToolTip(item.error_message)
|
||||
self._table.setItem(row, _COL_STATUS, status_cell)
|
||||
|
||||
def _update_row(self, row: int, item: DownloadItem) -> None:
|
||||
from PySide6.QtGui import QColor
|
||||
status_cell = self._table.item(row, _COL_STATUS)
|
||||
if status_cell:
|
||||
status_cell.setText(_STATUS_LABELS.get(item.status, item.status))
|
||||
status_cell.setForeground(QColor(_STATUS_COLOURS.get(item.status, '#808080')))
|
||||
status_cell.setToolTip(item.error_message or "")
|
||||
|
||||
def _refresh_header(self) -> None:
|
||||
items = self._manager.items
|
||||
total = len(items)
|
||||
complete = sum(1 for i in items if i.status == 'complete')
|
||||
skipped = sum(1 for i in items if i.status == 'skipped')
|
||||
remaining = total - complete - skipped
|
||||
|
||||
pct = int(complete / total * 100) if total > 0 else 0
|
||||
self._progress_bar.setValue(pct)
|
||||
self._progress_label.setText(
|
||||
f"{complete} of {total} complete | {skipped} deferred | {remaining} remaining"
|
||||
)
|
||||
self._retry_btn.setText(f"Retry Deferred ({skipped})")
|
||||
self._retry_btn.setEnabled(skipped > 0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Slots
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_item_updated_slot(self, item: DownloadItem) -> None:
|
||||
row = self._row_map.get(item.file_name)
|
||||
if row is not None:
|
||||
self._update_row(row, item)
|
||||
self._refresh_header()
|
||||
|
||||
def _on_concurrent_changed(self, value: int) -> None:
|
||||
self._manager.set_concurrent_limit(value)
|
||||
try:
|
||||
cfg = ConfigHandler()
|
||||
cfg.set("manual_download_concurrent_limit", int(value))
|
||||
cfg.save_config()
|
||||
except Exception:
|
||||
logger.debug("Could not persist manual_download_concurrent_limit", exc_info=True)
|
||||
|
||||
def _on_pick_folder(self) -> None:
|
||||
chosen = QFileDialog.getExistingDirectory(self, "Select watch folder", str(self._watch_dir))
|
||||
if chosen:
|
||||
from jackify.backend.services.download_watcher_service import WatcherConfig
|
||||
self._watch_dir = Path(chosen)
|
||||
self._folder_label.setText(chosen)
|
||||
self._manager._watch_dir = self._watch_dir
|
||||
self._manager._watcher._config.watch_directory = self._watch_dir
|
||||
self._manager._watcher._known = {}
|
||||
try:
|
||||
cfg = ConfigHandler()
|
||||
cfg.set("manual_download_watch_directory", str(self._watch_dir))
|
||||
cfg.save_config()
|
||||
except Exception:
|
||||
logger.debug("Could not persist manual_download_watch_directory", exc_info=True)
|
||||
|
||||
def _on_start_pause_clicked(self) -> None:
|
||||
if not self._started:
|
||||
self._started = True
|
||||
self._paused = False
|
||||
self._start_pause_btn.setText("Pause")
|
||||
self._manager.resume()
|
||||
return
|
||||
|
||||
if not self._paused:
|
||||
self._paused = True
|
||||
self._start_pause_btn.setText("Resume")
|
||||
self._manager.pause()
|
||||
else:
|
||||
self._paused = False
|
||||
self._start_pause_btn.setText("Pause")
|
||||
self._manager.resume()
|
||||
|
||||
def _on_retry_skipped(self) -> None:
|
||||
with self._manager._lock:
|
||||
for item in self._manager._items:
|
||||
if item.status in ('deferred', 'skipped'):
|
||||
item.status = 'pending'
|
||||
item.needs_user_retry = False
|
||||
row = self._row_map.get(item.file_name)
|
||||
if row is not None:
|
||||
self._update_row(row, item)
|
||||
self._manager._open_next_tabs()
|
||||
self._refresh_header()
|
||||
|
||||
def _on_defer_selected(self) -> None:
|
||||
row = self._table.currentRow()
|
||||
if row < 0:
|
||||
return
|
||||
file_item = self._table.item(row, _COL_NAME)
|
||||
if file_item is None:
|
||||
return
|
||||
file_name = file_item.text().strip()
|
||||
if not file_name:
|
||||
return
|
||||
self._manager.skip_item(file_name)
|
||||
|
||||
def _on_open_selected(self) -> None:
|
||||
row = self._table.currentRow()
|
||||
if row < 0:
|
||||
return
|
||||
file_item = self._table.item(row, _COL_NAME)
|
||||
if file_item is None:
|
||||
return
|
||||
file_name = file_item.text().strip()
|
||||
if not file_name:
|
||||
return
|
||||
self._manager.reopen_item(file_name)
|
||||
|
||||
def _on_row_double_clicked(self, row: int, _column: int) -> None:
|
||||
file_item = self._table.item(row, _COL_NAME)
|
||||
if file_item is None:
|
||||
return
|
||||
file_name = file_item.text()
|
||||
if not file_name:
|
||||
return
|
||||
self._manager.reopen_item(file_name)
|
||||
|
||||
def _on_all_done_slot(self, completed: int, skipped: int) -> None:
|
||||
from PySide6.QtCore import QTimer
|
||||
self._progress_label.setText(
|
||||
f"All downloads complete ({completed} accepted, {skipped} deferred) — closing..."
|
||||
)
|
||||
# Raise now while the dialog is still visible so the user sees the completion state
|
||||
self._raise_main_window()
|
||||
QTimer.singleShot(2000, self._close_and_refocus)
|
||||
|
||||
def _close_and_refocus(self) -> None:
|
||||
self.close()
|
||||
# Closing a non-modal dialog can hand focus back to whatever was behind it
|
||||
self._raise_main_window()
|
||||
|
||||
def _raise_main_window(self) -> None:
|
||||
try:
|
||||
win = self.window()
|
||||
if win:
|
||||
win.raise_()
|
||||
win.activateWindow()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
# Don't stop the manager on close - install continues
|
||||
event.accept()
|
||||
@@ -274,13 +274,20 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
|
||||
self.config_handler.set("proton_path", resolved_install_path)
|
||||
self.config_handler.set("proton_version", resolved_install_version)
|
||||
else:
|
||||
# No Proton found - don't write anything, let engine auto-detect
|
||||
# No Proton found - clear persisted selection so startup normalization
|
||||
# can auto-heal once a compatible Proton is installed.
|
||||
logger.warning("Auto Proton selection failed: No Proton versions found")
|
||||
# Don't modify existing config values
|
||||
resolved_install_path = None
|
||||
resolved_install_version = None
|
||||
self.config_handler.set("proton_path", None)
|
||||
self.config_handler.set("proton_version", None)
|
||||
except Exception as e:
|
||||
# Exception during detection - log it and don't write anything
|
||||
# Exception during detection - clear persisted selection to avoid stale path usage.
|
||||
logger.error(f"Auto Proton selection failed with exception: {e}", exc_info=True)
|
||||
# Don't modify existing config values
|
||||
resolved_install_path = None
|
||||
resolved_install_version = None
|
||||
self.config_handler.set("proton_path", None)
|
||||
self.config_handler.set("proton_version", None)
|
||||
else:
|
||||
# User selected specific Proton version
|
||||
resolved_install_path = selected_install_proton_path
|
||||
@@ -392,4 +399,3 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
|
||||
label = QLabel(text)
|
||||
label.setStyleSheet("font-weight: bold; color: #fff;")
|
||||
return label
|
||||
|
||||
|
||||
@@ -86,6 +86,8 @@ class SuccessDialog(QDialog):
|
||||
modlist_name_html = f'<span style="color:#3fb7d6; font-size:17px; font-weight:500;">{self.modlist_name}</span>'
|
||||
if self.workflow_type == "install":
|
||||
suffix_text = "installed successfully!"
|
||||
elif self.workflow_type == "update":
|
||||
suffix_text = "updated successfully!"
|
||||
elif self.workflow_type == "configure_new":
|
||||
suffix_text = "configured successfully!"
|
||||
elif self.workflow_type == "configure_existing":
|
||||
@@ -220,6 +222,7 @@ class SuccessDialog(QDialog):
|
||||
"""
|
||||
workflow_messages = {
|
||||
"install": f"{self.modlist_name} installed successfully!",
|
||||
"update": f"{self.modlist_name} updated successfully!",
|
||||
"configure_new": f"{self.modlist_name} configured successfully!",
|
||||
"configure_existing": f"{self.modlist_name} configuration updated successfully!",
|
||||
"tuxborn": f"Tuxborn installation completed successfully!",
|
||||
@@ -268,4 +271,4 @@ class SuccessDialog(QDialog):
|
||||
QApplication.quit()
|
||||
except Exception as e:
|
||||
logger.error(f"Error during safe exit: {e}")
|
||||
QApplication.quit()
|
||||
QApplication.quit()
|
||||
|
||||
@@ -44,6 +44,12 @@ class MainWindowGeometryMixin:
|
||||
|
||||
def _is_compact_mode(self) -> bool:
|
||||
try:
|
||||
if hasattr(self, 'wabbajack_installer_screen') and hasattr(self.wabbajack_installer_screen, 'show_details_checkbox'):
|
||||
if self.wabbajack_installer_screen.show_details_checkbox.isChecked():
|
||||
return False
|
||||
if hasattr(self, 'install_mo2_screen') and hasattr(self.install_mo2_screen, 'show_details_checkbox'):
|
||||
if self.install_mo2_screen.show_details_checkbox.isChecked():
|
||||
return False
|
||||
if hasattr(self, 'install_modlist_screen') and hasattr(self.install_modlist_screen, 'show_details_checkbox'):
|
||||
if self.install_modlist_screen.show_details_checkbox.isChecked():
|
||||
return False
|
||||
|
||||
@@ -220,6 +220,10 @@ class MainWindowUIMixin:
|
||||
stacked_widget=self.stacked_widget, additional_tasks_index=3, system_info=self.system_info
|
||||
)
|
||||
self.install_mo2_screen = screen
|
||||
try:
|
||||
screen.resize_request.connect(self._on_child_resize_request)
|
||||
except Exception:
|
||||
pass
|
||||
return screen
|
||||
|
||||
def _debug_screen_change(self, index):
|
||||
|
||||
@@ -32,6 +32,7 @@ from .configure_existing_modlist_shortcuts import ConfigureExistingModlistShortc
|
||||
from .configure_existing_modlist_console import ConfigureExistingModlistConsoleMixin
|
||||
from .screen_back_mixin import ScreenBackMixin
|
||||
from .install_modlist_ttw import TTWIntegrationMixin
|
||||
from .install_modlist_postinstall import PostInstallFeedbackMixin
|
||||
|
||||
class ConfigureExistingModlistScreen(
|
||||
ScreenBackMixin,
|
||||
@@ -40,23 +41,35 @@ class ConfigureExistingModlistScreen(
|
||||
ConfigureExistingModlistWorkflowMixin,
|
||||
ConfigureExistingModlistShortcutsMixin,
|
||||
ConfigureExistingModlistConsoleMixin,
|
||||
PostInstallFeedbackMixin,
|
||||
QWidget,
|
||||
):
|
||||
resize_request = Signal(str)
|
||||
|
||||
def cleanup_processes(self):
|
||||
"""Clean up any running processes when the window closes or is cancelled"""
|
||||
# Stop CPU tracking if active
|
||||
if hasattr(self, 'file_progress_list'):
|
||||
self.file_progress_list.stop_cpu_tracking()
|
||||
|
||||
# Clean up configuration thread if running
|
||||
if hasattr(self, 'config_thread') and self.config_thread.isRunning():
|
||||
self.config_thread.terminate()
|
||||
self.config_thread.wait(1000)
|
||||
from PySide6.QtCore import QThread
|
||||
for attr_name, value in list(vars(self).items()):
|
||||
try:
|
||||
if isinstance(value, QThread) and value.isRunning():
|
||||
try:
|
||||
value.finished_signal.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
value.terminate()
|
||||
value.wait(2000)
|
||||
setattr(self, attr_name, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def cancel_and_cleanup(self):
|
||||
"""Handle Cancel button - clean up processes and go back"""
|
||||
if getattr(self, '_vnv_controller', None) is not None:
|
||||
self._vnv_controller.cleanup()
|
||||
self._vnv_controller = None
|
||||
self.cleanup_processes()
|
||||
self.collapse_show_details_before_leave()
|
||||
self.go_back()
|
||||
@@ -65,16 +78,8 @@ class ConfigureExistingModlistScreen(
|
||||
"""Called when the widget becomes visible - ensure collapsed state"""
|
||||
super().showEvent(event)
|
||||
|
||||
# Ensure initial collapsed layout first so UI is stable before async load
|
||||
try:
|
||||
from PySide6.QtCore import Qt as _Qt
|
||||
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._toggle_console_visibility(False)
|
||||
|
||||
# Only set minimum size - DO NOT RESIZE
|
||||
self.force_collapsed_details_state()
|
||||
main_window = self.window()
|
||||
if main_window:
|
||||
from PySide6.QtCore import QSize
|
||||
@@ -118,8 +123,15 @@ class ConfigureExistingModlistScreen(
|
||||
return
|
||||
|
||||
# Check for VNV post-install automation after configuration
|
||||
if install_dir:
|
||||
self._check_and_run_vnv_automation(modlist_name, install_dir)
|
||||
if install_dir and self._check_and_run_vnv_automation(modlist_name, install_dir):
|
||||
self._pending_success_dialog_params = {
|
||||
'modlist_name': modlist_name,
|
||||
'workflow_type': 'configure_existing',
|
||||
'time_taken': self._calculate_time_taken(),
|
||||
'game_name': getattr(self, '_current_game_name', None),
|
||||
'enb_detected': enb_detected,
|
||||
}
|
||||
return
|
||||
|
||||
# Calculate time taken
|
||||
time_taken = self._calculate_time_taken()
|
||||
@@ -202,10 +214,15 @@ class ConfigureExistingModlistScreen(
|
||||
|
||||
# Re-enable controls (in case they were disabled from previous errors)
|
||||
self._enable_controls_after_operation()
|
||||
self.force_collapsed_details_state()
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up any running threads when the screen is closed"""
|
||||
logger.debug("DEBUG: cleanup called - cleaning up ConfigurationThread")
|
||||
|
||||
if getattr(self, '_vnv_controller', None) is not None:
|
||||
self._vnv_controller.cleanup()
|
||||
self._vnv_controller = None
|
||||
|
||||
# Clean up config thread if running
|
||||
if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning():
|
||||
|
||||
@@ -51,6 +51,12 @@ class ConfigureExistingModlistUIMixin:
|
||||
self.progress_indicator = OverallProgressIndicator(show_progress_bar=True)
|
||||
self.progress_indicator.set_status("Ready to configure", 0)
|
||||
self.file_progress_list = FileProgressList()
|
||||
self._post_install_sequence = self._build_post_install_sequence()
|
||||
self._post_install_total_steps = len(self._post_install_sequence)
|
||||
self._post_install_current_step = 0
|
||||
self._post_install_active = False
|
||||
self._post_install_last_label = ""
|
||||
self._bsa_hold_deadline = 0.0
|
||||
|
||||
# Create "Show Details" checkbox
|
||||
self.show_details_checkbox = QCheckBox("Show details")
|
||||
@@ -539,4 +545,3 @@ class ConfigureExistingModlistUIMixin:
|
||||
self.process_monitor.setPlainText('\n'.join(filtered))
|
||||
except Exception as e:
|
||||
self.process_monitor.setPlainText(f"[process info unavailable: {e}]")
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Workflow management for ConfigureExistingModlistScreen (Mixin)."""
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from jackify.shared.resolution_utils import get_resolution_fallback
|
||||
from jackify.shared.errors import configuration_failed
|
||||
|
||||
@@ -188,7 +189,10 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
)
|
||||
|
||||
if not success:
|
||||
self.error_occurred.emit("Configuration failed - check logs for details")
|
||||
self.error_occurred.emit(
|
||||
"Configuration did not complete successfully. "
|
||||
"Review the latest workflow output above for the failing step."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
@@ -206,89 +210,64 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
self._safe_append_text(f"[ERROR] Failed to start configuration: {e}")
|
||||
MessageService.show_error(self, configuration_failed(str(e)))
|
||||
|
||||
def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str):
|
||||
"""Check if VNV automation should run and execute if applicable
|
||||
def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str) -> bool:
|
||||
"""Check if VNV automation should run and start it if applicable.
|
||||
|
||||
Args:
|
||||
modlist_name: Name of the installed modlist
|
||||
install_dir: Installation directory path
|
||||
Returns:
|
||||
True if VNV automation is starting (caller should defer success dialog)
|
||||
False if no VNV needed (show success dialog immediately)
|
||||
"""
|
||||
try:
|
||||
from pathlib import Path
|
||||
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable, should_offer_vnv_automation
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
from ..services.vnv_automation_controller import VNVAutomationController
|
||||
|
||||
# Get paths first (needed for VNV detection)
|
||||
install_path = Path(install_dir)
|
||||
|
||||
# Quick check before importing more (pass install location for ModOrganizer.ini check)
|
||||
if not should_offer_vnv_automation(modlist_name, install_path):
|
||||
return
|
||||
game_paths = PathHandler().find_vanilla_game_paths()
|
||||
game_root = game_paths.get('Fallout New Vegas')
|
||||
self._vnv_controller = VNVAutomationController()
|
||||
return self._vnv_controller.attempt(
|
||||
parent=self,
|
||||
modlist_name=modlist_name,
|
||||
install_dir=install_dir,
|
||||
on_progress=self._safe_append_text,
|
||||
on_complete=self._on_vnv_complete,
|
||||
begin_feedback=self._begin_post_install_feedback,
|
||||
handle_feedback=self._handle_post_install_progress,
|
||||
)
|
||||
|
||||
if not game_root:
|
||||
logger.debug("DEBUG: VNV automation skipped - FNV game root not found")
|
||||
return
|
||||
|
||||
# Confirmation callback - show dialog to user
|
||||
def confirmation_callback(description: str) -> bool:
|
||||
from ..services.message_service import MessageService
|
||||
reply = MessageService.question(
|
||||
self,
|
||||
"VNV Post-Install Automation",
|
||||
description,
|
||||
critical=False,
|
||||
safety_level="medium"
|
||||
)
|
||||
return reply == QMessageBox.Yes
|
||||
|
||||
# Manual file callback for non-Premium users
|
||||
def manual_file_callback(title: str, instructions: str) -> Optional[Path]:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
from ..services.message_service import MessageService
|
||||
|
||||
# Show instructions
|
||||
MessageService.information(self, title, instructions)
|
||||
|
||||
# Open file picker
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
title,
|
||||
str(Path.home() / "Downloads"),
|
||||
"All Files (*.*)"
|
||||
)
|
||||
|
||||
if file_path:
|
||||
return Path(file_path).resolve()
|
||||
return None
|
||||
|
||||
# Run automation
|
||||
automation_ran, error = run_vnv_automation_if_applicable(
|
||||
modlist_name=modlist_name,
|
||||
modlist_install_location=install_path,
|
||||
game_root=game_root,
|
||||
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
|
||||
progress_callback=None, # GUI doesn't need progress updates for post-install
|
||||
manual_file_callback=manual_file_callback,
|
||||
confirmation_callback=confirmation_callback
|
||||
def _on_vnv_complete(self, success: bool, error: str):
|
||||
"""Handle VNV automation completion and show deferred success dialog."""
|
||||
self._end_post_install_feedback(not bool(error))
|
||||
if not success and error:
|
||||
from ..services.message_service import MessageService
|
||||
MessageService.warning(
|
||||
self,
|
||||
"VNV Automation Failed",
|
||||
f"VNV post-install automation encountered an error:\n\n{error}\n\n"
|
||||
"You can complete these steps manually by following the guide at:\n"
|
||||
"https://vivanewvegas.moddinglinked.com/wabbajack.html"
|
||||
)
|
||||
elif success:
|
||||
self._safe_append_text("VNV post-install automation completed successfully.")
|
||||
|
||||
if error:
|
||||
from ..services.message_service import MessageService
|
||||
MessageService.warning(
|
||||
self,
|
||||
"VNV Automation Failed",
|
||||
f"VNV post-install automation encountered an error:\n\n{error}\n\n"
|
||||
"You can complete these steps manually by following the guide at:\n"
|
||||
"https://vivanewvegas.moddinglinked.com/wabbajack.html"
|
||||
)
|
||||
if hasattr(self, '_pending_success_dialog_params'):
|
||||
params = self._pending_success_dialog_params
|
||||
del self._pending_success_dialog_params
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"ERROR: Failed to run VNV automation: {e}")
|
||||
import traceback
|
||||
logger.debug(f"Traceback: {traceback.format_exc()}")
|
||||
self.file_progress_list.clear()
|
||||
|
||||
from ..dialogs import SuccessDialog
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=params['modlist_name'],
|
||||
workflow_type=params['workflow_type'],
|
||||
time_taken=params['time_taken'],
|
||||
game_name=params['game_name'],
|
||||
parent=self,
|
||||
)
|
||||
success_dialog.show()
|
||||
|
||||
if params.get('enb_detected'):
|
||||
try:
|
||||
from ..dialogs.enb_proton_dialog import ENBProtonDialog
|
||||
enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self)
|
||||
enb_dialog.exec()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to show ENB dialog: %s", e)
|
||||
|
||||
def show_manual_steps_dialog(self, extra_warning=""):
|
||||
modlist_name = self.shortcut_combo.currentText().split('(')[0].strip() or "your modlist"
|
||||
@@ -372,4 +351,3 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
return f"{elapsed_minutes} minutes {elapsed_seconds_remainder} seconds"
|
||||
else:
|
||||
return f"{elapsed_seconds_remainder} seconds"
|
||||
|
||||
|
||||
@@ -35,14 +35,18 @@ from .configure_new_modlist_workflow import ConfigureNewModlistWorkflowMixin
|
||||
from .configure_new_modlist_dialogs import ConfigureNewModlistDialogsMixin, ModlistFetchThread, SelectionDialog
|
||||
from .screen_back_mixin import ScreenBackMixin
|
||||
from .install_modlist_ttw import TTWIntegrationMixin
|
||||
from .install_modlist_postinstall import PostInstallFeedbackMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, QWidget):
|
||||
class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, PostInstallFeedbackMixin, QWidget):
|
||||
resize_request = Signal(str)
|
||||
|
||||
def cancel_and_cleanup(self):
|
||||
"""Handle Cancel button - clean up processes and go back"""
|
||||
if getattr(self, '_vnv_controller', None) is not None:
|
||||
self._vnv_controller.cleanup()
|
||||
self._vnv_controller = None
|
||||
self.cleanup_processes()
|
||||
self.collapse_show_details_before_leave()
|
||||
self.go_back()
|
||||
@@ -50,23 +54,7 @@ class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureN
|
||||
def showEvent(self, event):
|
||||
"""Called when the widget becomes visible - ensure collapsed state"""
|
||||
super().showEvent(event)
|
||||
|
||||
# Ensure initial collapsed layout each time this screen is opened
|
||||
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
|
||||
# Set console to hidden state without emitting signals
|
||||
self.console.setVisible(False)
|
||||
self.resize_request.emit("compact")
|
||||
except Exception as e:
|
||||
# If initial collapse fails, log but don't crash
|
||||
print(f"Warning: Failed to set initial collapsed state: {e}")
|
||||
self.force_collapsed_details_state()
|
||||
|
||||
def on_configuration_complete(self, success, message, modlist_name, enb_detected=False):
|
||||
"""Handle configuration completion (same as Tuxborn)"""
|
||||
@@ -88,8 +76,15 @@ class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureN
|
||||
return
|
||||
|
||||
# Check for VNV post-install automation after configuration
|
||||
if install_dir:
|
||||
self._check_and_run_vnv_automation(modlist_name, install_dir)
|
||||
if install_dir and self._check_and_run_vnv_automation(modlist_name, install_dir):
|
||||
self._pending_success_dialog_params = {
|
||||
'modlist_name': modlist_name,
|
||||
'workflow_type': 'configure_new',
|
||||
'time_taken': self._calculate_time_taken(),
|
||||
'game_name': getattr(self, '_current_game_name', None),
|
||||
'enb_detected': enb_detected,
|
||||
}
|
||||
return
|
||||
|
||||
# Calculate time taken
|
||||
time_taken = self._calculate_time_taken()
|
||||
@@ -97,7 +92,6 @@ class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureN
|
||||
# Clear Activity window before showing success dialog
|
||||
self.file_progress_list.clear()
|
||||
|
||||
# Show success dialog with celebration
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=modlist_name,
|
||||
workflow_type="configure_new",
|
||||
@@ -106,16 +100,14 @@ class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureN
|
||||
parent=self
|
||||
)
|
||||
success_dialog.show()
|
||||
|
||||
# Show ENB Proton dialog if ENB was detected (use stored detection result, no re-detection)
|
||||
|
||||
if enb_detected:
|
||||
try:
|
||||
from ..dialogs.enb_proton_dialog import ENBProtonDialog
|
||||
enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self)
|
||||
enb_dialog.exec() # Modal dialog - blocks until user clicks OK
|
||||
enb_dialog.exec()
|
||||
except Exception as e:
|
||||
# Non-blocking: if dialog fails, just log and continue
|
||||
logger.warning(f"Failed to show ENB dialog: {e}")
|
||||
logger.warning("Failed to show ENB dialog: %s", e)
|
||||
else:
|
||||
self._safe_append_text(f"Configuration failed: {message}")
|
||||
MessageService.show_error(self, configuration_failed(str(message)))
|
||||
@@ -169,10 +161,15 @@ class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureN
|
||||
|
||||
# Re-enable controls (in case they were disabled from previous errors)
|
||||
self._enable_controls_after_operation()
|
||||
self.force_collapsed_details_state()
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up any running threads when the screen is closed"""
|
||||
logger.debug("DEBUG: cleanup called - cleaning up threads")
|
||||
|
||||
if getattr(self, '_vnv_controller', None) is not None:
|
||||
self._vnv_controller.cleanup()
|
||||
self._vnv_controller = None
|
||||
|
||||
# Clean up automated prefix thread if running
|
||||
if hasattr(self, 'automated_prefix_thread') and self.automated_prefix_thread and self.automated_prefix_thread.isRunning():
|
||||
|
||||
@@ -3,13 +3,14 @@ import os
|
||||
import re
|
||||
import time
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtCore import QTimer, Qt
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from jackify.shared.progress_models import FileProgress, OperationType
|
||||
from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
|
||||
|
||||
|
||||
class ConfigureNewModlistConsoleMixin:
|
||||
class ConfigureNewModlistConsoleMixin(FocusReclaimMixin):
|
||||
"""Mixin providing console output management for ConfigureNewModlistScreen."""
|
||||
|
||||
def _handle_progress_update(self, text):
|
||||
@@ -26,10 +27,12 @@ class ConfigureNewModlistConsoleMixin:
|
||||
self._stop_component_install_pulse()
|
||||
self.progress_indicator.set_status("Restarting Steam...", 20)
|
||||
self.file_progress_list.update_or_add_item("__phase__", "Restarting Steam...", 0.0)
|
||||
elif "steam restart" in message_lower and "success" in message_lower:
|
||||
elif "steam started successfully" in message_lower or ("steam restart" in message_lower and "success" in message_lower):
|
||||
self._stop_component_install_pulse()
|
||||
self.progress_indicator.set_status("Steam restarted successfully", 30)
|
||||
self.file_progress_list.update_or_add_item("__phase__", "Steam restarted", 0.0)
|
||||
elif STEAM_RESTART_SENTINEL in text:
|
||||
self._start_focus_reclaim_retries()
|
||||
elif "creating proton prefix" in message_lower or "prefix creation" in message_lower:
|
||||
self._stop_component_install_pulse()
|
||||
self.progress_indicator.set_status("Creating Proton prefix...", 50)
|
||||
@@ -169,4 +172,3 @@ class ConfigureNewModlistConsoleMixin:
|
||||
if file:
|
||||
self.install_dir_edit.setText(os.path.realpath(file))
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Dialog management for ConfigureNewModlistScreen (Mixin)."""
|
||||
import os
|
||||
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout, QFileDialog, QMessageBox, QApplication, QListWidget, QListWidgetItem
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtGui import QTextCursor
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import subprocess
|
||||
from jackify.frontends.gui.dialogs.existing_setup_dialog import prompt_existing_setup_dialog
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from jackify.shared.errors import manual_steps_incomplete
|
||||
import logging
|
||||
@@ -75,131 +77,85 @@ class SelectionDialog(QDialog):
|
||||
class ConfigureNewModlistDialogsMixin:
|
||||
"""Mixin providing dialog management for ConfigureNewModlistScreen."""
|
||||
|
||||
def _restore_controls_after_shortcut_dialog_abort(self):
|
||||
"""Return Configure New to an editable state when shortcut resolution is aborted."""
|
||||
try:
|
||||
self._enable_controls_after_operation()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def cleanup_processes(self):
|
||||
"""Clean up any running processes when the window closes or is cancelled"""
|
||||
# Stop CPU tracking if active
|
||||
if hasattr(self, 'file_progress_list'):
|
||||
self.file_progress_list.stop_cpu_tracking()
|
||||
|
||||
# Clean up automated prefix thread if running
|
||||
if hasattr(self, 'automated_prefix_thread') and self.automated_prefix_thread.isRunning():
|
||||
self.automated_prefix_thread.terminate()
|
||||
self.automated_prefix_thread.wait(1000)
|
||||
|
||||
# Clean up configuration thread if running
|
||||
if hasattr(self, 'config_thread') and self.config_thread.isRunning():
|
||||
self.config_thread.terminate()
|
||||
self.config_thread.wait(1000)
|
||||
from PySide6.QtCore import QThread
|
||||
for attr_name, value in list(vars(self).items()):
|
||||
try:
|
||||
if isinstance(value, QThread) and value.isRunning():
|
||||
value.terminate()
|
||||
value.wait(2000)
|
||||
setattr(self, attr_name, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def show_shortcut_conflict_dialog(self, conflicts):
|
||||
"""Show dialog to resolve shortcut name conflicts"""
|
||||
"""Show dialog to reuse an existing shortcut or choose a new name."""
|
||||
conflict_names = [c['name'] for c in conflicts]
|
||||
conflict_info = f"Found existing Steam shortcut: '{conflict_names[0]}'"
|
||||
|
||||
existing_name = conflict_names[0]
|
||||
|
||||
modlist_name = self.modlist_name_edit.text().strip()
|
||||
|
||||
# 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)
|
||||
|
||||
install_dir = os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip()
|
||||
|
||||
action, new_name = prompt_existing_setup_dialog(
|
||||
self,
|
||||
window_title="Existing Modlist Setup Detected",
|
||||
heading="Modlist Update or New Install",
|
||||
body=(
|
||||
"Jackify detected an existing Steam shortcut for this setup.\n\n"
|
||||
"If you are updating an existing modlist or reconfiguring it, choose "
|
||||
"'Use Existing Setup'. If you want a separate Steam entry, enter a different "
|
||||
"name and choose 'Create New Shortcut'."
|
||||
),
|
||||
existing_name=existing_name,
|
||||
requested_name=modlist_name,
|
||||
install_dir=install_dir,
|
||||
field_label="New shortcut name",
|
||||
reuse_label="Use Existing Setup",
|
||||
new_label="Create New Shortcut",
|
||||
cancel_label="Cancel",
|
||||
)
|
||||
|
||||
# Connect signals
|
||||
def on_create():
|
||||
new_name = name_input.text().strip()
|
||||
if action == "new":
|
||||
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.backend.services.message_service import MessageService
|
||||
MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.")
|
||||
self._restore_controls_after_shortcut_dialog_abort()
|
||||
else:
|
||||
# Empty name
|
||||
from jackify.backend.services.message_service import MessageService
|
||||
MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.")
|
||||
|
||||
def on_cancel():
|
||||
dialog.reject()
|
||||
self._restore_controls_after_shortcut_dialog_abort()
|
||||
elif action == "reuse":
|
||||
existing_appid = conflicts[0].get('appid')
|
||||
if not existing_appid:
|
||||
MessageService.warning(
|
||||
self,
|
||||
"Existing Setup Not Found",
|
||||
"Jackify could not determine the Steam AppID for the existing shortcut.",
|
||||
)
|
||||
self._restore_controls_after_shortcut_dialog_abort()
|
||||
return
|
||||
self._safe_append_text(f"Reusing existing Steam shortcut '{existing_name}'.")
|
||||
self.continue_configuration_after_automated_prefix(
|
||||
str(existing_appid),
|
||||
existing_name,
|
||||
install_dir,
|
||||
None,
|
||||
)
|
||||
else:
|
||||
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()
|
||||
self._restore_controls_after_shortcut_dialog_abort()
|
||||
|
||||
def retry_automated_workflow_with_new_name(self, new_name):
|
||||
"""Retry the automated workflow with a new shortcut name"""
|
||||
@@ -228,89 +184,64 @@ class ConfigureNewModlistDialogsMixin:
|
||||
MessageService.show_error(self, manual_steps_incomplete())
|
||||
self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self.modlist_name_edit.text().strip())
|
||||
|
||||
def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str):
|
||||
"""Check if VNV automation should run and execute if applicable
|
||||
def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str) -> bool:
|
||||
"""Check if VNV automation should run and start it if applicable.
|
||||
|
||||
Args:
|
||||
modlist_name: Name of the installed modlist
|
||||
install_dir: Installation directory path
|
||||
Returns:
|
||||
True if VNV automation is starting (caller should defer success dialog)
|
||||
False if no VNV needed (show success dialog immediately)
|
||||
"""
|
||||
try:
|
||||
from pathlib import Path
|
||||
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable, should_offer_vnv_automation
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
from ..services.vnv_automation_controller import VNVAutomationController
|
||||
|
||||
# Get paths first (needed for VNV detection)
|
||||
install_path = Path(install_dir)
|
||||
|
||||
# Quick check before importing more (pass install location for ModOrganizer.ini check)
|
||||
if not should_offer_vnv_automation(modlist_name, install_path):
|
||||
return
|
||||
game_paths = PathHandler().find_vanilla_game_paths()
|
||||
game_root = game_paths.get('Fallout New Vegas')
|
||||
self._vnv_controller = VNVAutomationController()
|
||||
return self._vnv_controller.attempt(
|
||||
parent=self,
|
||||
modlist_name=modlist_name,
|
||||
install_dir=install_dir,
|
||||
on_progress=self._safe_append_text,
|
||||
on_complete=self._on_vnv_complete,
|
||||
begin_feedback=self._begin_post_install_feedback,
|
||||
handle_feedback=self._handle_post_install_progress,
|
||||
)
|
||||
|
||||
if not game_root:
|
||||
logger.debug("DEBUG: VNV automation skipped - FNV game root not found")
|
||||
return
|
||||
|
||||
# Confirmation callback - show dialog to user
|
||||
def confirmation_callback(description: str) -> bool:
|
||||
from ..services.message_service import MessageService
|
||||
reply = MessageService.question(
|
||||
self,
|
||||
"VNV Post-Install Automation",
|
||||
description,
|
||||
critical=False,
|
||||
safety_level="medium"
|
||||
)
|
||||
return reply == QMessageBox.Yes
|
||||
|
||||
# Manual file callback for non-Premium users
|
||||
def manual_file_callback(title: str, instructions: str) -> Optional[Path]:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
from ..services.message_service import MessageService
|
||||
|
||||
# Show instructions
|
||||
MessageService.information(self, title, instructions)
|
||||
|
||||
# Open file picker
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
title,
|
||||
str(Path.home() / "Downloads"),
|
||||
"All Files (*.*)"
|
||||
)
|
||||
|
||||
if file_path:
|
||||
return Path(file_path).resolve()
|
||||
return None
|
||||
|
||||
# Run automation
|
||||
automation_ran, error = run_vnv_automation_if_applicable(
|
||||
modlist_name=modlist_name,
|
||||
modlist_install_location=install_path,
|
||||
game_root=game_root,
|
||||
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
|
||||
progress_callback=None, # GUI doesn't need progress updates for post-install
|
||||
manual_file_callback=manual_file_callback,
|
||||
confirmation_callback=confirmation_callback
|
||||
def _on_vnv_complete(self, success: bool, error: str):
|
||||
"""Handle VNV automation completion and show deferred success dialog."""
|
||||
self._end_post_install_feedback(not bool(error))
|
||||
if not success and error:
|
||||
from ..services.message_service import MessageService
|
||||
MessageService.warning(
|
||||
self,
|
||||
"VNV Automation Failed",
|
||||
f"VNV post-install automation encountered an error:\n\n{error}\n\n"
|
||||
"You can complete these steps manually by following the guide at:\n"
|
||||
"https://vivanewvegas.moddinglinked.com/wabbajack.html"
|
||||
)
|
||||
elif success:
|
||||
self._safe_append_text("VNV post-install automation completed successfully.")
|
||||
|
||||
if error:
|
||||
from ..services.message_service import MessageService
|
||||
MessageService.warning(
|
||||
self,
|
||||
"VNV Automation Failed",
|
||||
f"VNV post-install automation encountered an error:\n\n{error}\n\n"
|
||||
"You can complete these steps manually by following the guide at:\n"
|
||||
"https://vivanewvegas.moddinglinked.com/wabbajack.html"
|
||||
)
|
||||
if hasattr(self, '_pending_success_dialog_params'):
|
||||
params = self._pending_success_dialog_params
|
||||
del self._pending_success_dialog_params
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"ERROR: Failed to run VNV automation: {e}")
|
||||
import traceback
|
||||
logger.debug(f"Traceback: {traceback.format_exc()}")
|
||||
self.file_progress_list.clear()
|
||||
|
||||
from ..dialogs import SuccessDialog
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=params['modlist_name'],
|
||||
workflow_type=params['workflow_type'],
|
||||
time_taken=params['time_taken'],
|
||||
game_name=params['game_name'],
|
||||
parent=self,
|
||||
)
|
||||
success_dialog.show()
|
||||
|
||||
if params.get('enb_detected'):
|
||||
try:
|
||||
from ..dialogs.enb_proton_dialog import ENBProtonDialog
|
||||
enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self)
|
||||
enb_dialog.exec()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to show ENB dialog: %s", e)
|
||||
|
||||
def show_next_steps_dialog(self, message):
|
||||
dlg = QDialog(self)
|
||||
@@ -335,4 +266,3 @@ class ConfigureNewModlistDialogsMixin:
|
||||
btn_return.clicked.connect(on_return)
|
||||
btn_exit.clicked.connect(on_exit)
|
||||
dlg.exec()
|
||||
|
||||
|
||||
@@ -55,6 +55,12 @@ class ConfigureNewModlistUISetupMixin:
|
||||
self.progress_indicator = OverallProgressIndicator(show_progress_bar=True)
|
||||
self.progress_indicator.set_status("Ready to configure", 0)
|
||||
self.file_progress_list = FileProgressList()
|
||||
self._post_install_sequence = self._build_post_install_sequence()
|
||||
self._post_install_total_steps = len(self._post_install_sequence)
|
||||
self._post_install_current_step = 0
|
||||
self._post_install_active = False
|
||||
self._post_install_last_label = ""
|
||||
self._bsa_hold_deadline = 0.0
|
||||
|
||||
# Create "Show Details" checkbox
|
||||
self.show_details_checkbox = QCheckBox("Show details")
|
||||
@@ -601,4 +607,3 @@ class ConfigureNewModlistUISetupMixin:
|
||||
f"Unable to verify protontricks installation: {e}\n\n"
|
||||
"Continuing anyway, but some features may not work correctly.")
|
||||
return True # Continue anyway
|
||||
|
||||
|
||||
@@ -145,7 +145,6 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
progress_update = Signal(str)
|
||||
workflow_complete = Signal(object) # Will emit the result tuple
|
||||
error_occurred = Signal(object) # error (JackifyError or str)
|
||||
|
||||
def __init__(self, modlist_name, install_dir, mo2_exe_path, steamdeck, auto_restart):
|
||||
super().__init__()
|
||||
self.modlist_name = modlist_name
|
||||
@@ -153,27 +152,23 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
self.mo2_exe_path = mo2_exe_path
|
||||
self.steamdeck = steamdeck
|
||||
self.auto_restart = auto_restart
|
||||
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
|
||||
# Initialize the automated prefix service
|
||||
|
||||
prefix_service = AutomatedPrefixService()
|
||||
|
||||
# Define progress callback for GUI updates
|
||||
|
||||
def progress_callback(message):
|
||||
self.progress_update.emit(message)
|
||||
|
||||
# Run the automated workflow (this contains the blocking operations)
|
||||
|
||||
result = prefix_service.run_working_workflow(
|
||||
self.modlist_name, self.install_dir, self.mo2_exe_path,
|
||||
self.modlist_name, self.install_dir, self.mo2_exe_path,
|
||||
progress_callback, steamdeck=self.steamdeck, auto_restart=self.auto_restart
|
||||
)
|
||||
|
||||
# Emit the result
|
||||
|
||||
self.workflow_complete.emit(result)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
from jackify.shared.errors import JackifyError, prefix_creation_failed
|
||||
if not isinstance(e, JackifyError):
|
||||
@@ -474,7 +469,10 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
)
|
||||
|
||||
if not success:
|
||||
self.error_occurred.emit("Configuration failed - check logs for details")
|
||||
self.error_occurred.emit(
|
||||
"Configuration did not complete successfully. "
|
||||
"Review the latest workflow output above for the failing step."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
@@ -509,4 +507,3 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
return f"{elapsed_minutes} minutes {elapsed_seconds_remainder} seconds"
|
||||
else:
|
||||
return f"{elapsed_seconds_remainder} seconds"
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ MO2SetupService. No Wabbajack modlist required.
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -18,11 +19,14 @@ from PySide6.QtWidgets import (
|
||||
from PySide6.QtCore import Qt, QThread, Signal, QSize
|
||||
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
from jackify.shared.errors import mo2_setup_failed
|
||||
from jackify.shared.progress_models import FileProgress, OperationType
|
||||
from ..dialogs.existing_setup_dialog import prompt_existing_setup_dialog
|
||||
from ..services.message_service import MessageService
|
||||
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
|
||||
from ..utils import set_responsive_minimum
|
||||
from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
|
||||
from ..widgets.progress_indicator import OverallProgressIndicator
|
||||
from ..widgets.file_progress_list import FileProgressList
|
||||
from .screen_back_mixin import ScreenBackMixin
|
||||
@@ -37,10 +41,11 @@ class MO2SetupWorker(QThread):
|
||||
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):
|
||||
def __init__(self, install_dir: Path, shortcut_name: str, existing_appid: int | None = None):
|
||||
super().__init__()
|
||||
self.install_dir = install_dir
|
||||
self.shortcut_name = shortcut_name
|
||||
self.existing_appid = existing_appid
|
||||
|
||||
def run(self):
|
||||
from jackify.backend.services.mo2_setup_service import MO2SetupService
|
||||
@@ -56,6 +61,7 @@ class MO2SetupWorker(QThread):
|
||||
success, app_id, error_msg = service.setup_mo2(
|
||||
install_dir=self.install_dir,
|
||||
shortcut_name=self.shortcut_name,
|
||||
existing_appid=self.existing_appid,
|
||||
progress_callback=_progress,
|
||||
should_cancel=self.isInterruptionRequested,
|
||||
)
|
||||
@@ -68,7 +74,7 @@ class MO2SetupWorker(QThread):
|
||||
self.setup_complete.emit(False, None, str(e))
|
||||
|
||||
|
||||
class InstallMO2Screen(ScreenBackMixin, QWidget):
|
||||
class InstallMO2Screen(ScreenBackMixin, FocusReclaimMixin, QWidget):
|
||||
"""Standalone MO2 setup screen"""
|
||||
|
||||
resize_request = Signal(str)
|
||||
@@ -90,6 +96,10 @@ class InstallMO2Screen(ScreenBackMixin, QWidget):
|
||||
self._user_manually_scrolled = False
|
||||
self._was_at_bottom = True
|
||||
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
self.log_path = get_jackify_logs_dir() / "MO2_Install_workflow.log"
|
||||
os.makedirs(os.path.dirname(self.log_path), exist_ok=True)
|
||||
|
||||
self.progress_indicator = OverallProgressIndicator(show_progress_bar=False)
|
||||
self.progress_indicator.set_status("Ready", 0)
|
||||
|
||||
@@ -281,7 +291,16 @@ class InstallMO2Screen(ScreenBackMixin, QWidget):
|
||||
|
||||
def _on_show_details_toggled(self, checked):
|
||||
self.console.setVisible(checked)
|
||||
self.resize_request.emit("expand" if checked else "collapse")
|
||||
if checked:
|
||||
self.console.setMinimumHeight(200)
|
||||
self.console.setMaximumHeight(16777215)
|
||||
self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.resize_request.emit("expand")
|
||||
else:
|
||||
self.console.setMinimumHeight(0)
|
||||
self.console.setMaximumHeight(0)
|
||||
self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
|
||||
self.resize_request.emit("compact")
|
||||
|
||||
def _browse_folder(self):
|
||||
folder = QFileDialog.getExistingDirectory(
|
||||
@@ -339,15 +358,75 @@ class InstallMO2Screen(ScreenBackMixin, QWidget):
|
||||
if confirm != QMessageBox.Yes:
|
||||
return
|
||||
|
||||
existing_appid = None
|
||||
candidate_exe = install_dir / "ModOrganizer.exe"
|
||||
prefix_service = AutomatedPrefixService()
|
||||
conflict_result = prefix_service.handle_existing_shortcut_conflict(
|
||||
shortcut_name,
|
||||
str(candidate_exe),
|
||||
str(install_dir),
|
||||
)
|
||||
if isinstance(conflict_result, list):
|
||||
action, new_name = prompt_existing_setup_dialog(
|
||||
self,
|
||||
window_title="Existing Modlist Setup Detected",
|
||||
heading="Use Existing Setup or Create a New Shortcut",
|
||||
body=(
|
||||
"Jackify found an existing Steam shortcut for this Mod Organizer 2 setup.\n\n"
|
||||
"Choose 'Use Existing Setup' to reuse the current Steam shortcut, or enter a "
|
||||
"different name to create a separate shortcut."
|
||||
),
|
||||
existing_name=conflict_result[0].get("name", shortcut_name),
|
||||
requested_name=shortcut_name,
|
||||
install_dir=str(install_dir),
|
||||
field_label="New shortcut name",
|
||||
reuse_label="Use Existing Setup",
|
||||
new_label="Create New Shortcut",
|
||||
cancel_label="Cancel",
|
||||
)
|
||||
if action == "reuse":
|
||||
existing_appid = conflict_result[0].get("appid")
|
||||
if not existing_appid:
|
||||
MessageService.warning(self, "Existing Setup Not Found", "Jackify could not determine the Steam AppID for the existing shortcut.")
|
||||
return
|
||||
self.console.append(f"Reusing existing Steam shortcut '{shortcut_name}'.")
|
||||
elif action == "new":
|
||||
if not new_name:
|
||||
MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.")
|
||||
return
|
||||
if new_name == shortcut_name:
|
||||
MessageService.warning(self, "Same Name", "Please enter a different name to create a separate shortcut.")
|
||||
return
|
||||
shortcut_name = new_name
|
||||
self.shortcut_name_edit.setText(new_name)
|
||||
else:
|
||||
self.console.append("Shortcut creation cancelled by user")
|
||||
return
|
||||
|
||||
self.console.clear()
|
||||
self.file_progress_list.clear()
|
||||
self.file_progress_list.start_cpu_tracking()
|
||||
|
||||
from jackify.backend.handlers.logging_handler import LoggingHandler
|
||||
log_handler = LoggingHandler()
|
||||
log_handler.rotate_log_file_per_run(self.log_path, backup_count=5)
|
||||
|
||||
self._write_to_log_file("=" * 60)
|
||||
self._write_to_log_file("MO2 Setup Started")
|
||||
self._write_to_log_file(f"Install directory: {install_dir}")
|
||||
self._write_to_log_file(f"Shortcut name: {shortcut_name}")
|
||||
if existing_appid:
|
||||
self._write_to_log_file(f"Existing AppID: {existing_appid}")
|
||||
self._write_to_log_file("=" * 60)
|
||||
|
||||
self.start_btn.setEnabled(False)
|
||||
self.cancel_btn.setEnabled(False)
|
||||
self.cancel_btn.setEnabled(True)
|
||||
self.cancel_btn.setText("Cancel Setup")
|
||||
self.shortcut_name_edit.setEnabled(False)
|
||||
self.install_dir_edit.setEnabled(False)
|
||||
self.progress_indicator.set_status("Starting...", 0)
|
||||
|
||||
self.worker = MO2SetupWorker(install_dir, shortcut_name)
|
||||
self.worker = MO2SetupWorker(install_dir, shortcut_name, int(existing_appid) if existing_appid else None)
|
||||
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)
|
||||
@@ -356,14 +435,25 @@ class InstallMO2Screen(ScreenBackMixin, QWidget):
|
||||
|
||||
def _on_progress_update(self, message: str):
|
||||
self.progress_indicator.set_status(message, 0)
|
||||
if STEAM_RESTART_SENTINEL in message:
|
||||
self._start_focus_reclaim_retries()
|
||||
|
||||
def _on_log_output(self, message: str):
|
||||
self._write_to_log_file(message)
|
||||
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 _write_to_log_file(self, message: str):
|
||||
try:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
with open(self.log_path, 'a', encoding='utf-8') as f:
|
||||
f.write(f"[{timestamp}] {message}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_setup_complete(self, success: bool, app_id, error_msg: str):
|
||||
self.file_progress_list.stop_cpu_tracking()
|
||||
|
||||
@@ -384,6 +474,9 @@ class InstallMO2Screen(ScreenBackMixin, QWidget):
|
||||
|
||||
self.start_btn.setEnabled(True)
|
||||
self.cancel_btn.setEnabled(True)
|
||||
self.cancel_btn.setText("Cancel")
|
||||
self.shortcut_name_edit.setEnabled(True)
|
||||
self.install_dir_edit.setEnabled(True)
|
||||
if self.worker is not None:
|
||||
try:
|
||||
self.worker.deleteLater()
|
||||
@@ -429,22 +522,11 @@ class InstallMO2Screen(ScreenBackMixin, QWidget):
|
||||
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")
|
||||
self.force_collapsed_details_state()
|
||||
|
||||
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")
|
||||
self.force_collapsed_details_state()
|
||||
try:
|
||||
main_window = self.window()
|
||||
if main_window:
|
||||
|
||||
@@ -415,6 +415,10 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO
|
||||
"""Clean up any running processes when the window closes or is cancelled"""
|
||||
logger.debug("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes")
|
||||
|
||||
if getattr(self, '_vnv_controller', None) is not None:
|
||||
self._vnv_controller.cleanup()
|
||||
self._vnv_controller = None
|
||||
|
||||
def _stop_thread(attr_name: str, cancel_method: Optional[str] = None, cooperative_ms: int = 5000, force_ms: int = 10000):
|
||||
thread = getattr(self, attr_name, None)
|
||||
if thread is None:
|
||||
@@ -470,12 +474,19 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO
|
||||
pass
|
||||
setattr(self, attr_name, None)
|
||||
|
||||
# Always stop installer thread first; this is the most likely source of QThread teardown aborts.
|
||||
# Always stop installer thread first; it needs cancel() not terminate().
|
||||
_stop_thread('install_thread', cancel_method='cancel', cooperative_ms=15000, force_ms=10000)
|
||||
|
||||
# Stop remaining worker threads.
|
||||
for thread_name in ('prefix_thread', 'config_thread', 'fetch_thread', '_gallery_cache_preload_thread'):
|
||||
_stop_thread(thread_name)
|
||||
# Stop any remaining QThread instances on this object, regardless of attribute name.
|
||||
from PySide6.QtCore import QThread
|
||||
for attr_name, value in list(vars(self).items()):
|
||||
if attr_name == 'install_thread':
|
||||
continue
|
||||
try:
|
||||
if isinstance(value, QThread):
|
||||
_stop_thread(attr_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def cancel_installation(self):
|
||||
"""Cancel the currently running installation"""
|
||||
@@ -499,6 +510,29 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO
|
||||
if hasattr(self, 'progress_indicator'):
|
||||
self.progress_indicator.set_status("Cancelled", None)
|
||||
|
||||
# Stop manual download manager and close dialog if active
|
||||
if getattr(self, '_manual_dl_manager', None) is not None:
|
||||
try:
|
||||
self._manual_dl_manager.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._manual_dl_manager = None
|
||||
if getattr(self, '_manual_dl_dialog', None) is not None:
|
||||
try:
|
||||
self._manual_dl_dialog.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._manual_dl_dialog = None
|
||||
if getattr(self, '_non_premium_info_dlg', None) is not None:
|
||||
try:
|
||||
self._non_premium_info_dlg.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._non_premium_info_dlg = None
|
||||
self._non_premium_gate_enabled = False
|
||||
self._non_premium_info_acknowledged = False
|
||||
self._pending_manual_download_events = None
|
||||
|
||||
# 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()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Configuration phase workflow for InstallModlistScreen (Mixin)."""
|
||||
from PySide6.QtWidgets import QMessageBox, QProgressDialog
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtCore import Qt, QThread, Signal, QTimer
|
||||
from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
|
||||
from PySide6.QtGui import QFont
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from jackify.shared.errors import manual_steps_incomplete, configuration_failed
|
||||
@@ -16,7 +17,7 @@ import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
from .install_modlist_shortcut_dialog import InstallModlistShortcutDialogMixin
|
||||
|
||||
class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
|
||||
class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMixin):
|
||||
"""Mixin providing configuration phase workflow and dialog management for InstallModlistScreen."""
|
||||
|
||||
def on_configuration_progress(self, progress_msg):
|
||||
@@ -43,14 +44,9 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
|
||||
pass
|
||||
finally:
|
||||
self.steam_restart_progress = None
|
||||
# Controls are managed by the proper control management system
|
||||
# Delay focus reclaim so Steam's window finishes painting before we steal it back
|
||||
try:
|
||||
from PySide6.QtCore import QTimer
|
||||
win = self.window()
|
||||
QTimer.singleShot(10000, lambda: (win.raise_(), win.activateWindow()))
|
||||
except Exception:
|
||||
pass
|
||||
# Controls are managed by the proper control management system.
|
||||
# Reclaim focus with bounded retries because Steam restart timing varies.
|
||||
self._start_focus_reclaim_retries()
|
||||
|
||||
def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str:
|
||||
"""Detect game type by checking ModOrganizer.ini for loader executables."""
|
||||
@@ -167,13 +163,20 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
|
||||
# No VNV automation - end post-install feedback now
|
||||
self._end_post_install_feedback(True)
|
||||
|
||||
if getattr(self, "_is_update_install", False):
|
||||
try:
|
||||
self._verify_update_ini_after_configuration(install_dir)
|
||||
except Exception as e:
|
||||
logger.warning("Update mode verify: failed post-config INI verification: %s", e)
|
||||
|
||||
# Clear Activity window before showing success dialog
|
||||
self.file_progress_list.clear()
|
||||
|
||||
# Show normal success dialog
|
||||
workflow_type = "update" if getattr(self, "_is_update_install", False) else "install"
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=modlist_name,
|
||||
workflow_type="install",
|
||||
workflow_type=workflow_type,
|
||||
time_taken=time_str,
|
||||
game_name=game_name,
|
||||
parent=self
|
||||
@@ -196,7 +199,19 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
|
||||
else:
|
||||
# Configuration failed for other reasons
|
||||
self._end_post_install_feedback(False)
|
||||
MessageService.show_error(self, configuration_failed("Post-install configuration failed."))
|
||||
MessageService.show_error(
|
||||
self,
|
||||
configuration_failed(
|
||||
"Post-install configuration failed.",
|
||||
context={
|
||||
"operation": "install_modlist",
|
||||
"step": "post_install_configuration",
|
||||
"modlist_name": modlist_name,
|
||||
"install_dir": install_dir,
|
||||
"workflow_type": "update" if getattr(self, "_is_update_install", False) else "install",
|
||||
},
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
# Ensure controls are re-enabled even on unexpected errors
|
||||
self._enable_controls_after_operation()
|
||||
@@ -206,7 +221,19 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
|
||||
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.show_error(self, configuration_failed(str(error_message)))
|
||||
MessageService.show_error(
|
||||
self,
|
||||
configuration_failed(
|
||||
str(error_message),
|
||||
context={
|
||||
"operation": "install_modlist",
|
||||
"step": "post_install_configuration",
|
||||
"modlist_name": self.modlist_name_edit.text().strip(),
|
||||
"install_dir": self.install_dir_edit.text().strip(),
|
||||
"workflow_type": "update" if getattr(self, "_is_update_install", False) else "install",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
# Re-enable all controls on error
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
@@ -155,49 +155,6 @@ class ConsoleOutputMixin:
|
||||
self._write_to_log_file(message)
|
||||
return
|
||||
|
||||
# CRITICAL: Detect token/auth errors and ALWAYS show them (even when not in debug mode)
|
||||
token_error_keywords = [
|
||||
'token has expired',
|
||||
'token expired',
|
||||
'oauth token',
|
||||
'authentication failed',
|
||||
'unauthorized',
|
||||
'401',
|
||||
'403',
|
||||
'refresh token',
|
||||
'authorization failed',
|
||||
'nexus.*premium.*required',
|
||||
'premium.*required',
|
||||
]
|
||||
|
||||
is_token_error = any(keyword in msg_lower for keyword in token_error_keywords)
|
||||
if is_token_error:
|
||||
if not self._token_error_notified:
|
||||
self._token_error_notified = True
|
||||
MessageService.critical(
|
||||
self,
|
||||
"Authentication Error",
|
||||
(
|
||||
"Nexus Mods authentication has failed. This may be due to:\n\n"
|
||||
"• OAuth token expired and refresh failed\n"
|
||||
"• Nexus Premium required for this modlist\n"
|
||||
"• Network connectivity issues\n\n"
|
||||
"Please check the console output (Show Details) for more information.\n"
|
||||
"You may need to re-authorize in Settings."
|
||||
),
|
||||
safety_level="high"
|
||||
)
|
||||
# Also show in console
|
||||
guidance = (
|
||||
"\n[Jackify] CRITICAL: Authentication/Token Error Detected!\n"
|
||||
"[Jackify] This may cause downloads to stop. Check the error message above.\n"
|
||||
"[Jackify] If OAuth token expired, go to Settings and re-authorize.\n"
|
||||
)
|
||||
self._safe_append_text(guidance)
|
||||
# Force console to be visible so user can see the error
|
||||
if not self.show_details_checkbox.isChecked():
|
||||
self.show_details_checkbox.setChecked(True)
|
||||
|
||||
# Detect known engine bugs and provide helpful guidance
|
||||
if 'destination array was not long enough' in msg_lower or \
|
||||
('argumentexception' in msg_lower and 'downloadmachineurl' in msg_lower):
|
||||
|
||||
@@ -3,6 +3,7 @@ InstallerThread: QThread subclass for running jackify-engine install.
|
||||
Signals are defined at class level (required for Qt signal/slot).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
@@ -12,7 +13,8 @@ from PySide6.QtCore import QThread, Signal
|
||||
import logging
|
||||
|
||||
from jackify.backend.utils.engine_error_parser import parse_engine_error_line, error_from_exit_code
|
||||
from jackify.shared.errors import JackifyError
|
||||
from jackify.backend.utils.cc_content_detector import is_cc_content_error, extract_cc_filename
|
||||
from jackify.shared.errors import JackifyError, cc_content_missing
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -24,6 +26,12 @@ class InstallerThread(QThread):
|
||||
progress_updated = Signal(object)
|
||||
installation_finished = Signal(bool, str)
|
||||
premium_required_detected = Signal(str)
|
||||
# Emitted when engine outputs a full batch of manual download items.
|
||||
# Payload: list of dicts with keys: file_name, nexus_url/download_url/url,
|
||||
# expected_size, mod_name, mod_id, file_id, index, total, loop_iteration
|
||||
manual_download_list_received = Signal(list)
|
||||
manual_download_phase_complete = Signal()
|
||||
non_premium_detected = Signal()
|
||||
|
||||
def __init__(self, modlist, install_dir, downloads_dir, api_key, modlist_name,
|
||||
install_mode='online', progress_state_manager=None, auth_service=None, oauth_info=None):
|
||||
@@ -40,16 +48,81 @@ class InstallerThread(QThread):
|
||||
self.auth_service = auth_service
|
||||
self.oauth_info = oauth_info
|
||||
self._premium_signal_sent = False
|
||||
self._non_premium_info_sent = False
|
||||
self._engine_output_buffer = []
|
||||
self._buffer_size = 10
|
||||
self.last_error: Optional[JackifyError] = None
|
||||
self._raw_stderr_lines: list = [] # bounded ring buffer for non-JSON stderr
|
||||
self._raw_stdout_lines: list = [] # bounded ring buffer for non-JSON stdout
|
||||
self._pending_manual_downloads: list = [] # accumulates items until list_complete
|
||||
self._resource_limit_hint: Optional[str] = None
|
||||
|
||||
@staticmethod
|
||||
def _is_generic_failure_text(message: Optional[str]) -> bool:
|
||||
text = (message or "").strip().lower()
|
||||
if not text:
|
||||
return True
|
||||
generic_markers = (
|
||||
"did not complete successfully",
|
||||
"unknown failure",
|
||||
"an install engine error occurred",
|
||||
"installation failed due to an engine error",
|
||||
)
|
||||
return any(marker in text for marker in generic_markers)
|
||||
|
||||
def cancel(self):
|
||||
self.cancelled = True
|
||||
if self.process_manager:
|
||||
self.process_manager.cancel()
|
||||
|
||||
def send_continue(self):
|
||||
"""Send the continue command to the engine after manual downloads are ready."""
|
||||
if self.process_manager:
|
||||
sent = self.process_manager.write_stdin('{"command":"continue"}')
|
||||
if sent:
|
||||
logger.info("[MDL-1014] Manual download continue command accepted by process stdin")
|
||||
else:
|
||||
logger.error("[MDL-9010] Failed to send continue command to engine (stdin unavailable or process exited)")
|
||||
|
||||
def _handle_engine_event(self, line: str) -> bool:
|
||||
"""
|
||||
Try to parse a stdout line as an engine workflow event.
|
||||
Returns True if the line was an event (caller should not emit it as output).
|
||||
"""
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith('{'):
|
||||
return False
|
||||
try:
|
||||
obj = json.loads(stripped)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return False
|
||||
|
||||
event = obj.get('event')
|
||||
if not event:
|
||||
return False
|
||||
|
||||
if event == 'manual_download_required':
|
||||
self._pending_manual_downloads.append(obj)
|
||||
return True
|
||||
|
||||
if event == 'manual_download_list_complete':
|
||||
loop_iter = obj.get('loop_iteration', 1)
|
||||
items = list(self._pending_manual_downloads)
|
||||
self._pending_manual_downloads.clear()
|
||||
for item in items:
|
||||
item['loop_iteration'] = loop_iter
|
||||
if items:
|
||||
logger.info(f"[MDL-1000] Engine manual download list complete | loop_iteration={loop_iter} items={len(items)}")
|
||||
self.manual_download_list_received.emit(items)
|
||||
return True
|
||||
|
||||
if event == 'manual_download_phase_complete':
|
||||
logger.info("[MDL-1015] Engine reported manual download phase complete")
|
||||
self.manual_download_phase_complete.emit()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _read_stderr(self):
|
||||
try:
|
||||
for raw in self.process_manager.proc.stderr:
|
||||
@@ -57,16 +130,116 @@ class InstallerThread(QThread):
|
||||
if not line:
|
||||
continue
|
||||
logger.debug(f"Engine stderr: {line}")
|
||||
self._raw_stderr_lines.append(line)
|
||||
if len(self._raw_stderr_lines) > 40:
|
||||
self._raw_stderr_lines.pop(0)
|
||||
error = parse_engine_error_line(line)
|
||||
if error and self.last_error is None:
|
||||
self.last_error = error
|
||||
else:
|
||||
self._raw_stderr_lines.append(line)
|
||||
if len(self._raw_stderr_lines) > 20:
|
||||
self._raw_stderr_lines.pop(0)
|
||||
if self.last_error is None and is_cc_content_error(line):
|
||||
self.last_error = cc_content_missing(extract_cc_filename(line) or "")
|
||||
except Exception as e:
|
||||
logger.debug(f"Stderr reader error: {e}")
|
||||
|
||||
def _remember_stdout_line(self, line: str) -> None:
|
||||
"""Keep a bounded tail of meaningful stdout lines for failure diagnostics."""
|
||||
cleaned = (line or "").strip()
|
||||
if not cleaned:
|
||||
return
|
||||
if cleaned.startswith("{"):
|
||||
return
|
||||
if cleaned.startswith("Installing files ") or cleaned.startswith("Extracting files "):
|
||||
return
|
||||
self._raw_stdout_lines.append(cleaned)
|
||||
if len(self._raw_stdout_lines) > 60:
|
||||
self._raw_stdout_lines.pop(0)
|
||||
|
||||
def _extract_root_cause_line(self) -> Optional[str]:
|
||||
"""Extract the most actionable error line from stderr/stdout tails."""
|
||||
combined = list(reversed(self._raw_stderr_lines)) + list(reversed(self._raw_stdout_lines))
|
||||
if not combined:
|
||||
return None
|
||||
|
||||
ignore_fragments = (
|
||||
"installation failed",
|
||||
"install failed",
|
||||
"exit code",
|
||||
"building bsa",
|
||||
"generating debug caches",
|
||||
)
|
||||
priority_fragments = (
|
||||
"too many open files",
|
||||
"file descriptor",
|
||||
"resource temporarily unavailable",
|
||||
"cannot increase file descriptor limit",
|
||||
"permission denied",
|
||||
"no space left on device",
|
||||
"traceback",
|
||||
"fatal",
|
||||
"exception",
|
||||
"error",
|
||||
"failed",
|
||||
"could not",
|
||||
"unable to",
|
||||
)
|
||||
|
||||
for raw in combined:
|
||||
lowered = raw.lower()
|
||||
if any(fragment in lowered for fragment in ignore_fragments):
|
||||
continue
|
||||
if any(fragment in lowered for fragment in priority_fragments):
|
||||
return raw
|
||||
|
||||
for raw in combined:
|
||||
lowered = raw.lower()
|
||||
if any(fragment in lowered for fragment in ignore_fragments):
|
||||
continue
|
||||
return raw
|
||||
|
||||
return None
|
||||
|
||||
def _build_failure_message(self, returncode: int) -> str:
|
||||
"""Build a user-facing failure message with the best available root cause."""
|
||||
root_cause = self._extract_root_cause_line()
|
||||
if root_cause:
|
||||
if self._resource_limit_hint and "file descriptor" not in root_cause.lower():
|
||||
return f"{root_cause}\n\nPossible contributing issue: {self._resource_limit_hint}"
|
||||
return root_cause
|
||||
|
||||
recent_lines = []
|
||||
for line in list(reversed(self._raw_stderr_lines)) + list(reversed(self._raw_stdout_lines)):
|
||||
cleaned = (line or "").strip()
|
||||
if not cleaned:
|
||||
continue
|
||||
lowered = cleaned.lower()
|
||||
if (
|
||||
"install failed" in lowered
|
||||
or "installation failed" in lowered
|
||||
or "exit code" in lowered
|
||||
or "building bsa" in lowered
|
||||
or "generating debug caches" in lowered
|
||||
):
|
||||
continue
|
||||
if cleaned not in recent_lines:
|
||||
recent_lines.append(cleaned)
|
||||
if len(recent_lines) >= 3:
|
||||
break
|
||||
|
||||
if recent_lines:
|
||||
recent_block = "\n- ".join(recent_lines)
|
||||
return (
|
||||
"Install engine reported errors.\n\n"
|
||||
f"Most recent engine output:\n- {recent_block}"
|
||||
)
|
||||
|
||||
if self._resource_limit_hint:
|
||||
return self._resource_limit_hint
|
||||
|
||||
return (
|
||||
"Install failed, but the engine did not provide a specific error line."
|
||||
)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.core.modlist_operations import get_jackify_engine_path
|
||||
@@ -101,8 +274,25 @@ class InstallerThread(QThread):
|
||||
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
|
||||
env_vars['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
|
||||
env = get_clean_subprocess_env(env_vars)
|
||||
|
||||
# Install-time resource preflight: keep this visible in workflow output so
|
||||
# users/support see hard-limit constraints even without debug logging.
|
||||
try:
|
||||
from jackify.backend.services.resource_manager import ResourceManager
|
||||
resource_manager = ResourceManager()
|
||||
status = resource_manager.get_limit_status()
|
||||
if status.get('current_hard', 0) < status.get('target_limit', 0):
|
||||
self._resource_limit_hint = (
|
||||
f"File descriptor hard limit is {status['current_hard']} "
|
||||
f"(target {status['target_limit']}); this can cause install failures. "
|
||||
"Increase ulimit and retry."
|
||||
)
|
||||
self.output_received.emit(f"[WARN] {self._resource_limit_hint}\n")
|
||||
except Exception as e:
|
||||
logger.debug(f"Resource preflight check failed: {e}")
|
||||
|
||||
from jackify.backend.handlers.subprocess_utils import ProcessManager
|
||||
self.process_manager = ProcessManager(cmd, env=env, text=False, separate_stderr=True)
|
||||
self.process_manager = ProcessManager(cmd, env=env, text=False, separate_stderr=True, enable_stdin=True)
|
||||
stderr_thread = threading.Thread(target=self._read_stderr, daemon=True)
|
||||
stderr_thread.start()
|
||||
ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]')
|
||||
@@ -161,6 +351,8 @@ class InstallerThread(QThread):
|
||||
self._engine_output_buffer.append(decoded.strip())
|
||||
if len(self._engine_output_buffer) > self._buffer_size:
|
||||
self._engine_output_buffer.pop(0)
|
||||
if self.last_error is None and is_cc_content_error(decoded):
|
||||
self.last_error = cc_content_missing(extract_cc_filename(decoded) or "")
|
||||
if self.progress_state_manager:
|
||||
updated = self.progress_state_manager.process_line(decoded)
|
||||
if updated:
|
||||
@@ -179,7 +371,7 @@ class InstallerThread(QThread):
|
||||
line = ansi_escape.sub(b'', line)
|
||||
decoded = line.decode('utf-8', errors='replace')
|
||||
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
|
||||
is_premium_error, matched_pattern = is_non_premium_indicator(decoded)
|
||||
is_premium_error, matched_pattern = (False, None) if decoded.strip().startswith('{') else is_non_premium_indicator(decoded)
|
||||
if not self._premium_signal_sent and is_premium_error:
|
||||
self._premium_signal_sent = True
|
||||
logger.warning("=" * 80)
|
||||
@@ -213,9 +405,14 @@ class InstallerThread(QThread):
|
||||
logger.warning("If user HAS Premium, this is a FALSE POSITIVE")
|
||||
logger.warning("=" * 80)
|
||||
self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required")
|
||||
if not self._non_premium_info_sent and 'non-premium' in decoded.lower() and 'routing' in decoded.lower():
|
||||
self._non_premium_info_sent = True
|
||||
self.non_premium_detected.emit()
|
||||
self._engine_output_buffer.append(decoded.strip())
|
||||
if len(self._engine_output_buffer) > self._buffer_size:
|
||||
self._engine_output_buffer.pop(0)
|
||||
if self.last_error is None and is_cc_content_error(decoded):
|
||||
self.last_error = cc_content_missing(extract_cc_filename(decoded) or "")
|
||||
config_handler = ConfigHandler()
|
||||
debug_mode = config_handler.get('debug_mode', False)
|
||||
if self.progress_state_manager:
|
||||
@@ -225,6 +422,10 @@ class InstallerThread(QThread):
|
||||
if progress_state.active_files and debug_mode:
|
||||
logger.debug(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}")
|
||||
self.progress_updated.emit(progress_state)
|
||||
if self._handle_engine_event(decoded):
|
||||
last_was_blank = False
|
||||
continue
|
||||
self._remember_stdout_line(decoded)
|
||||
if '[FILE_PROGRESS]' in decoded:
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
@@ -246,6 +447,7 @@ class InstallerThread(QThread):
|
||||
if parts[0].strip():
|
||||
self.output_received.emit(parts[0].rstrip())
|
||||
else:
|
||||
self._remember_stdout_line(decoded)
|
||||
self.output_received.emit(decoded)
|
||||
stderr_thread.join(timeout=5)
|
||||
returncode = self.process_manager.wait()
|
||||
@@ -265,14 +467,18 @@ class InstallerThread(QThread):
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Error reading remaining output: {e}")
|
||||
if returncode != 0 and not self.cancelled and self.last_error is None:
|
||||
stderr_detail = "\n".join(self._raw_stderr_lines[-10:]) if self._raw_stderr_lines else ""
|
||||
detail = f"Exit code {returncode}.\n\nEngine output:\n{stderr_detail}" if stderr_detail else f"Exit code {returncode}."
|
||||
stderr_tail = self._raw_stderr_lines[-10:] if self._raw_stderr_lines else []
|
||||
stdout_tail = self._raw_stdout_lines[-10:] if self._raw_stdout_lines else []
|
||||
combined_tail = stderr_tail + stdout_tail
|
||||
tail_text = "\n".join(combined_tail)
|
||||
detail = f"Exit code {returncode}.\n\nEngine output:\n{tail_text}" if tail_text else f"Exit code {returncode}."
|
||||
fallback = error_from_exit_code(
|
||||
returncode,
|
||||
detail,
|
||||
context={
|
||||
"exit_code": returncode,
|
||||
"stderr_tail_lines": len(self._raw_stderr_lines[-10:]),
|
||||
"stderr_tail_lines": len(stderr_tail),
|
||||
"stdout_tail_lines": len(stdout_tail),
|
||||
},
|
||||
)
|
||||
if fallback:
|
||||
@@ -283,8 +489,14 @@ class InstallerThread(QThread):
|
||||
elif returncode == 0:
|
||||
self.installation_finished.emit(True, "Installation completed successfully")
|
||||
else:
|
||||
error_msg = f"Installation failed (exit code {returncode})"
|
||||
logger.debug(f"DEBUG: Engine exited with code {returncode}")
|
||||
if self.last_error:
|
||||
error_msg = self.last_error.message or ""
|
||||
if self._is_generic_failure_text(error_msg):
|
||||
error_msg = self._build_failure_message(returncode)
|
||||
self.last_error.message = error_msg
|
||||
else:
|
||||
error_msg = self._build_failure_message(returncode)
|
||||
logger.error(f"Engine install failed | exit_code={returncode} summary={error_msg}")
|
||||
self.installation_finished.emit(False, error_msg)
|
||||
except Exception as e:
|
||||
self.installation_finished.emit(False, f"Installation error: {str(e)}")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Nexus authentication methods for InstallModlistScreen (Mixin)."""
|
||||
from PySide6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QMessageBox, QProgressDialog, QApplication
|
||||
from PySide6.QtCore import Qt, QTimer, QThread, Signal
|
||||
from PySide6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QMessageBox, QApplication
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtGui import QDesktopServices, QGuiApplication
|
||||
import logging
|
||||
import webbrowser
|
||||
@@ -47,7 +47,6 @@ class NexusAuthMixin:
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(15)
|
||||
|
||||
# Explanation label
|
||||
info_label = QLabel(
|
||||
"Could not open browser automatically.\n\n"
|
||||
"Please copy the URL below and paste it into your browser:"
|
||||
@@ -56,11 +55,10 @@ class NexusAuthMixin:
|
||||
info_label.setStyleSheet("color: #ccc; font-size: 12px;")
|
||||
layout.addWidget(info_label)
|
||||
|
||||
# URL input (read-only but selectable)
|
||||
url_input = QLineEdit()
|
||||
url_input.setText(url)
|
||||
url_input.setReadOnly(True)
|
||||
url_input.selectAll() # Pre-select text for easy copying
|
||||
url_input.selectAll()
|
||||
url_input.setStyleSheet("""
|
||||
QLineEdit {
|
||||
background-color: #1a1a1a;
|
||||
@@ -74,11 +72,9 @@ class NexusAuthMixin:
|
||||
""")
|
||||
layout.addWidget(url_input)
|
||||
|
||||
# Button row
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.addStretch()
|
||||
|
||||
# Copy button
|
||||
copy_btn = QPushButton("Copy URL")
|
||||
copy_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
@@ -101,7 +97,6 @@ class NexusAuthMixin:
|
||||
copy_btn.clicked.connect(copy_to_clipboard)
|
||||
button_layout.addWidget(copy_btn)
|
||||
|
||||
# Close button
|
||||
close_btn = QPushButton("Close")
|
||||
close_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
@@ -119,7 +114,113 @@ class NexusAuthMixin:
|
||||
button_layout.addWidget(close_btn)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
dialog.setLayout(layout)
|
||||
dialog.exec()
|
||||
|
||||
def _show_oauth_paste_dialog(self):
|
||||
"""Show dialog for pasting jackify:// callback URL as manual fallback."""
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Paste Callback URL")
|
||||
dialog.setModal(True)
|
||||
dialog.setMinimumWidth(560)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(12)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
|
||||
info_label = QLabel(
|
||||
"If your browser did not complete the flow automatically:\n\n"
|
||||
"1. Click Continue in your browser if you have not already.\n"
|
||||
"2. If a URL starting with jackify:// appears in your browser\n"
|
||||
" address bar, copy it and paste it below."
|
||||
)
|
||||
info_label.setWordWrap(True)
|
||||
info_label.setStyleSheet("color: #ccc; font-size: 12px;")
|
||||
layout.addWidget(info_label)
|
||||
|
||||
url_input = QLineEdit()
|
||||
url_input.setPlaceholderText("jackify://oauth/callback?code=...&state=...")
|
||||
url_input.setStyleSheet("""
|
||||
QLineEdit {
|
||||
background-color: #1a1a1a;
|
||||
color: #3fd0ea;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
""")
|
||||
layout.addWidget(url_input)
|
||||
|
||||
error_label = QLabel("")
|
||||
error_label.setStyleSheet("color: #f44336; font-size: 11px;")
|
||||
error_label.setWordWrap(True)
|
||||
layout.addWidget(error_label)
|
||||
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_layout.addStretch()
|
||||
|
||||
submit_btn = QPushButton("Submit")
|
||||
submit_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #3fd0ea;
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #5fdfff;
|
||||
}
|
||||
""")
|
||||
|
||||
def on_submit():
|
||||
url = url_input.text().strip()
|
||||
if not url.startswith('jackify://oauth/callback'):
|
||||
error_label.setText("URL must start with jackify://oauth/callback")
|
||||
return
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
params = urllib.parse.parse_qs(parsed.query)
|
||||
code = params.get('code', [None])[0]
|
||||
state = params.get('state', [None])[0]
|
||||
if not code or not state:
|
||||
error_label.setText("URL is missing required code or state parameter.")
|
||||
return
|
||||
callback_file = Path.home() / ".config" / "jackify" / "oauth_callback.tmp"
|
||||
try:
|
||||
callback_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
callback_file.write_text(f"{code}\n{state}")
|
||||
logger.info("OAuth callback written via manual paste")
|
||||
dialog.accept()
|
||||
except Exception as e:
|
||||
error_label.setText(f"Failed to write callback: {e}")
|
||||
|
||||
submit_btn.clicked.connect(on_submit)
|
||||
url_input.returnPressed.connect(on_submit)
|
||||
btn_layout.addWidget(submit_btn)
|
||||
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #444;
|
||||
color: #ccc;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 20px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
""")
|
||||
cancel_btn.clicked.connect(dialog.reject)
|
||||
btn_layout.addWidget(cancel_btn)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
dialog.setLayout(layout)
|
||||
dialog.exec()
|
||||
|
||||
@@ -129,13 +230,11 @@ class NexusAuthMixin:
|
||||
|
||||
authenticated, method, _ = self.auth_service.get_auth_status()
|
||||
if authenticated and method == 'oauth':
|
||||
# OAuth is active - offer to revoke
|
||||
reply = MessageService.question(self, "Revoke", "Revoke OAuth authorisation?", safety_level="low")
|
||||
if reply == QMessageBox.Yes:
|
||||
self.auth_service.revoke_oauth()
|
||||
self._update_nexus_status()
|
||||
else:
|
||||
# Not authorised or using API key - offer to authorise with OAuth
|
||||
reply = MessageService.question(self, "Authorise with Nexus",
|
||||
"Your browser will open for Nexus authorisation.\n\n"
|
||||
"Note: Your browser may ask permission to open 'xdg-open'\n"
|
||||
@@ -146,33 +245,82 @@ class NexusAuthMixin:
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
|
||||
# Create progress dialog
|
||||
progress = QProgressDialog(
|
||||
"Waiting for authorisation...\n\nPlease check your browser.",
|
||||
"Cancel",
|
||||
0, 0,
|
||||
self
|
||||
)
|
||||
progress.setWindowTitle("Nexus OAuth")
|
||||
progress.setWindowModality(Qt.WindowModal)
|
||||
progress.setMinimumDuration(0)
|
||||
progress.setMinimumWidth(400)
|
||||
# Build waiting dialog with paste fallback always accessible
|
||||
wait_dialog = QDialog(self)
|
||||
wait_dialog.setWindowTitle("Nexus OAuth")
|
||||
wait_dialog.setWindowModality(Qt.WindowModal)
|
||||
wait_dialog.setMinimumWidth(420)
|
||||
|
||||
wait_layout = QVBoxLayout()
|
||||
wait_layout.setSpacing(12)
|
||||
wait_layout.setContentsMargins(20, 20, 20, 20)
|
||||
|
||||
wait_label = QLabel(
|
||||
"Waiting for authorisation...\n\n"
|
||||
"Please complete authorisation in your browser.\n\n"
|
||||
"Your browser may ask permission to open Jackify — click Open or Allow."
|
||||
)
|
||||
wait_label.setWordWrap(True)
|
||||
wait_label.setStyleSheet("color: #ccc; font-size: 12px;")
|
||||
wait_layout.addWidget(wait_label)
|
||||
|
||||
wait_layout.addStretch()
|
||||
|
||||
btn_layout = QHBoxLayout()
|
||||
|
||||
paste_btn = QPushButton("Paste callback URL")
|
||||
paste_btn.setToolTip(
|
||||
"If your browser shows a jackify:// URL after clicking Continue, paste it here."
|
||||
)
|
||||
paste_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #333;
|
||||
color: #aaa;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #444;
|
||||
color: #ccc;
|
||||
}
|
||||
""")
|
||||
paste_btn.clicked.connect(self._show_oauth_paste_dialog)
|
||||
btn_layout.addWidget(paste_btn)
|
||||
|
||||
btn_layout.addStretch()
|
||||
|
||||
# Track cancellation
|
||||
oauth_cancelled = [False]
|
||||
|
||||
def on_cancel():
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #444;
|
||||
color: #ccc;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
""")
|
||||
def on_cancel_click():
|
||||
oauth_cancelled[0] = True
|
||||
wait_dialog.close()
|
||||
cancel_btn.clicked.connect(on_cancel_click)
|
||||
btn_layout.addWidget(cancel_btn)
|
||||
|
||||
progress.canceled.connect(on_cancel)
|
||||
progress.show()
|
||||
wait_layout.addLayout(btn_layout)
|
||||
wait_dialog.setLayout(wait_layout)
|
||||
wait_dialog.show()
|
||||
QApplication.processEvents()
|
||||
|
||||
# Create OAuth thread to prevent GUI freeze
|
||||
class OAuthThread(QThread):
|
||||
finished_signal = Signal(bool)
|
||||
message_signal = Signal(str)
|
||||
manual_url_signal = Signal(str) # Signal when browser fails to open
|
||||
manual_url_signal = Signal(str)
|
||||
|
||||
def __init__(self, auth_service, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -180,9 +328,7 @@ class NexusAuthMixin:
|
||||
|
||||
def run(self):
|
||||
def show_message(msg):
|
||||
# Check if this is a "browser failed" message with URL
|
||||
if "Could not open browser" in msg and "Please open this URL manually:" in msg:
|
||||
# Extract URL from message
|
||||
url_start = msg.find("Please open this URL manually:") + len("Please open this URL manually:")
|
||||
url = msg[url_start:].strip()
|
||||
self.manual_url_signal.emit(url)
|
||||
@@ -194,23 +340,20 @@ class NexusAuthMixin:
|
||||
|
||||
oauth_thread = OAuthThread(self.auth_service, self)
|
||||
|
||||
# Connect message signal to update progress dialog
|
||||
def update_progress_message(msg):
|
||||
if not oauth_cancelled[0]:
|
||||
progress.setLabelText(f"Waiting for authorisation...\n\n{msg}")
|
||||
wait_label.setText(f"Waiting for authorisation...\n\n{msg}")
|
||||
QApplication.processEvents()
|
||||
|
||||
# Connect manual URL signal to show copyable dialog
|
||||
def show_manual_url_dialog(url):
|
||||
if not oauth_cancelled[0]:
|
||||
progress.hide() # Hide progress dialog temporarily
|
||||
wait_dialog.hide()
|
||||
self._show_copyable_url_dialog(url)
|
||||
progress.show()
|
||||
wait_dialog.show()
|
||||
|
||||
oauth_thread.message_signal.connect(update_progress_message)
|
||||
oauth_thread.manual_url_signal.connect(show_manual_url_dialog)
|
||||
|
||||
# Wait for thread completion
|
||||
oauth_success = [False]
|
||||
def on_oauth_finished(success):
|
||||
oauth_success[0] = success
|
||||
@@ -218,25 +361,21 @@ class NexusAuthMixin:
|
||||
oauth_thread.finished_signal.connect(on_oauth_finished)
|
||||
oauth_thread.start()
|
||||
|
||||
# Wait for thread to finish (non-blocking event loop)
|
||||
while oauth_thread.isRunning():
|
||||
QApplication.processEvents()
|
||||
oauth_thread.wait(100) # Check every 100ms
|
||||
oauth_thread.wait(100)
|
||||
if oauth_cancelled[0]:
|
||||
# User cancelled - thread will still complete but we ignore result
|
||||
oauth_thread.wait(2000)
|
||||
if oauth_thread.isRunning():
|
||||
oauth_thread.terminate()
|
||||
break
|
||||
|
||||
progress.close()
|
||||
wait_dialog.close()
|
||||
QApplication.processEvents()
|
||||
|
||||
self._update_nexus_status()
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
# Check success first - if OAuth succeeded, ignore cancellation flag
|
||||
# (progress dialog close can trigger cancel handler even on success)
|
||||
if oauth_success[0]:
|
||||
_, _, username = self.auth_service.get_auth_status()
|
||||
if username:
|
||||
@@ -250,11 +389,10 @@ class NexusAuthMixin:
|
||||
MessageService.warning(
|
||||
self,
|
||||
"Authorisation Failed",
|
||||
"OAuth authorisation failed.\n\n"
|
||||
"If your browser showed a blank page (e.g. Firefox on Steam Deck),\n"
|
||||
"try again and use 'Paste callback URL' to paste the URL from the address bar.\n\n"
|
||||
"If you see 'redirect URI mismatch', the OAuth redirect URI must be configured by Nexus.\n\n"
|
||||
"You can configure an API key in Settings as a fallback.",
|
||||
"OAuth authorisation timed out.\n\n"
|
||||
"If your browser shows a URL starting with jackify:// after\n"
|
||||
"clicking Continue, try again and use 'Paste callback URL'\n"
|
||||
"during the wait to complete authorisation manually.\n\n"
|
||||
"If the issue persists, an API key can be configured in Settings.",
|
||||
safety_level="medium"
|
||||
)
|
||||
|
||||
|
||||
@@ -229,6 +229,8 @@ class PostInstallFeedbackMixin:
|
||||
prev_step = self._post_install_sequence[self._post_install_current_step - 1]
|
||||
if prev_step['id'] == 'wine_components' and step['id'] != 'wine_components':
|
||||
self._stop_component_install_pulse()
|
||||
if prev_step['id'] == 'vnv_bsa_decompress' and step['id'] != 'vnv_bsa_decompress':
|
||||
self._stop_bsa_decompress_pulse()
|
||||
|
||||
self._post_install_current_step = idx
|
||||
self._post_install_last_label = step['label']
|
||||
@@ -250,6 +252,9 @@ class PostInstallFeedbackMixin:
|
||||
self._start_component_install_pulse_with_components(comp_list)
|
||||
break
|
||||
|
||||
if step['id'] == 'vnv_bsa_decompress':
|
||||
self._start_bsa_decompress_pulse()
|
||||
|
||||
# Keep Activity window in sync with progress banner
|
||||
# If we're already in wine_components step, check for component list updates
|
||||
# Skip _update_post_install_ui() for wine_components - pulser manages Activity window directly
|
||||
@@ -402,6 +407,7 @@ class PostInstallFeedbackMixin:
|
||||
if not self._post_install_active:
|
||||
return
|
||||
self._stop_component_install_pulse()
|
||||
self._stop_bsa_decompress_pulse()
|
||||
total = max(1, self._post_install_total_steps)
|
||||
final_step = total if success else max(0, self._post_install_current_step)
|
||||
label = "Post-installation complete" if success else "Post-installation stopped"
|
||||
@@ -468,3 +474,18 @@ class PostInstallFeedbackMixin:
|
||||
if hasattr(self, '_component_install_list'):
|
||||
del self._component_install_list
|
||||
|
||||
def _start_bsa_decompress_pulse(self):
|
||||
"""Keep the Activity window alive during long BSA decompression runs."""
|
||||
self.file_progress_list.update_or_add_item("__vnv_bsa__", "VNV: Decompressing BSA files...", 0.0)
|
||||
if not getattr(self, '_bsa_decompress_timer', None):
|
||||
self._bsa_decompress_timer = QTimer(self)
|
||||
self._bsa_decompress_timer.timeout.connect(self._bsa_decompress_heartbeat)
|
||||
self._bsa_decompress_timer.start(250)
|
||||
|
||||
def _bsa_decompress_heartbeat(self):
|
||||
self.file_progress_list.update_or_add_item("__vnv_bsa__", "VNV: Decompressing BSA files...", 0.0)
|
||||
|
||||
def _stop_bsa_decompress_pulse(self):
|
||||
if hasattr(self, '_bsa_decompress_timer') and self._bsa_decompress_timer:
|
||||
self._bsa_decompress_timer.stop()
|
||||
self._bsa_decompress_timer = None
|
||||
|
||||
@@ -8,6 +8,7 @@ from jackify.shared.progress_models import InstallationPhase, OperationType, Ins
|
||||
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
|
||||
import time
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
class ProgressHandlersMixin:
|
||||
@@ -53,6 +54,52 @@ class ProgressHandlersMixin:
|
||||
if hasattr(self, 'install_thread') and self.install_thread:
|
||||
self.install_thread.cancel()
|
||||
|
||||
def on_non_premium_detected(self):
|
||||
"""Gate the manual-download dialog until non-premium info has been acknowledged."""
|
||||
self._non_premium_gate_enabled = True
|
||||
self._non_premium_info_acknowledged = False
|
||||
logger.info("[MDL-1002] Non-premium flow detected; info dialog will show when manual downloads arrive")
|
||||
|
||||
def _show_non_premium_info_dialog(self):
|
||||
"""Show the non-premium information dialog. Blocks (nested event loop) until user clicks OK.
|
||||
|
||||
Called from on_manual_download_list_received, so it only appears when files actually
|
||||
need manual downloading. The engine is paused waiting for a continue signal at that
|
||||
point, so process_finished will not fire and close the dialog prematurely.
|
||||
"""
|
||||
from PySide6.QtCore import Qt
|
||||
if getattr(self, '_non_premium_info_dlg', None) is not None:
|
||||
return
|
||||
if getattr(self, '_non_premium_info_acknowledged', False):
|
||||
return
|
||||
|
||||
box = QMessageBox(self)
|
||||
box.setWindowTitle("Non-Premium Account Detected")
|
||||
box.setIcon(QMessageBox.Information)
|
||||
box.setWindowModality(Qt.WindowModal)
|
||||
box.setTextFormat(Qt.RichText)
|
||||
box.setText(
|
||||
"<b>Jackify has detected that your Nexus account does not have Premium.</b>"
|
||||
"<br><br>"
|
||||
"The install will proceed in the following stages:"
|
||||
"<ol>"
|
||||
"<li>Automatically download any mods available from non-Nexus sources</li>"
|
||||
"<li>After you click OK here, open a manual download dialog listing all remaining manual archives</li>"
|
||||
"</ol>"
|
||||
"When your browser opens a Nexus page, click <b>\"Slow Download\"</b>."
|
||||
" For non-Nexus manual links, follow the site instructions shown in the page.<br><br>"
|
||||
"<b>Watch folder:</b> Jackify watches the folder shown in that dialog for newly downloaded files. "
|
||||
"Files detected there are validated and moved automatically into your modlist downloads folder — "
|
||||
"you do not need to move files manually. If your browser saves to a different location, "
|
||||
"please set the Watch Folder to that directory before starting the download of mod archives."
|
||||
)
|
||||
box.setStandardButtons(QMessageBox.Ok)
|
||||
self._non_premium_info_dlg = box
|
||||
box.exec()
|
||||
self._non_premium_info_dlg = None
|
||||
self._non_premium_info_acknowledged = True
|
||||
logger.info("[MDL-1003] Non-premium information dialog acknowledged by user")
|
||||
|
||||
def on_progress_updated(self, progress_state):
|
||||
"""R&D: Handle structured progress updates from parser"""
|
||||
# Calculate proper overall progress during BSA building
|
||||
@@ -321,11 +368,16 @@ class ProgressHandlersMixin:
|
||||
from jackify.backend.utils.modlist_meta import write_modlist_meta
|
||||
thread = getattr(self, 'install_thread', None)
|
||||
if thread and getattr(thread, 'install_dir', None) and getattr(thread, 'modlist_name', None):
|
||||
modlist_version = None
|
||||
if getattr(thread, 'install_mode', 'online') == 'online':
|
||||
info = getattr(self, 'selected_modlist_info', None) or {}
|
||||
modlist_version = info.get('version')
|
||||
write_modlist_meta(
|
||||
thread.install_dir,
|
||||
thread.modlist_name,
|
||||
getattr(self, '_current_game_type', None),
|
||||
install_mode=getattr(thread, 'install_mode', 'online'),
|
||||
modlist_version=modlist_version,
|
||||
)
|
||||
except Exception as _meta_err:
|
||||
logger.debug(f"Modlist meta write skipped: {_meta_err}")
|
||||
@@ -337,6 +389,19 @@ class ProgressHandlersMixin:
|
||||
else:
|
||||
# Reset to initial state on failure
|
||||
self.progress_indicator.reset()
|
||||
cancellation_detected = (
|
||||
(isinstance(message, str) and "cancelled by user" in message.lower())
|
||||
or bool(getattr(self, '_cancellation_requested', False))
|
||||
)
|
||||
if cancellation_detected:
|
||||
self._installation_cancelled = True
|
||||
logger.info("Installation cancelled by user")
|
||||
if self.show_details_checkbox.isChecked():
|
||||
self._safe_append_text("\nInstallation cancelled by user.")
|
||||
# Use a distinct non-success code and let process_finished route this
|
||||
# through the cancellation UX path (not failure path).
|
||||
self.process_finished(130, QProcess.NormalExit)
|
||||
return
|
||||
|
||||
if self._premium_failure_active:
|
||||
message = "Installation stopped because Nexus Premium is required for automated downloads."
|
||||
@@ -359,9 +424,38 @@ class ProgressHandlersMixin:
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
logger.debug("DEBUG: Button states reset in process_finished")
|
||||
|
||||
# Stop manual download manager if it is still running (e.g. install failed mid-phase)
|
||||
if getattr(self, '_manual_dl_manager', None) is not None:
|
||||
try:
|
||||
self._manual_dl_manager.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._manual_dl_manager = None
|
||||
if getattr(self, '_manual_dl_dialog', None) is not None:
|
||||
try:
|
||||
self._manual_dl_dialog.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._manual_dl_dialog = None
|
||||
if getattr(self, '_non_premium_info_dlg', None) is not None:
|
||||
try:
|
||||
self._non_premium_info_dlg.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._non_premium_info_dlg = None
|
||||
self._non_premium_gate_enabled = False
|
||||
self._non_premium_info_acknowledged = False
|
||||
self._pending_manual_download_events = None
|
||||
|
||||
|
||||
if exit_code == 0:
|
||||
if getattr(self, "_is_update_install", False):
|
||||
try:
|
||||
install_dir = os.path.realpath(self.install_dir_edit.text().strip())
|
||||
self._record_post_engine_ini_snapshot_and_diff(install_dir)
|
||||
except Exception as e:
|
||||
logger.warning("Update mode: failed post-engine MO2 snapshot/diff: %s", e)
|
||||
# Check if this was an unsupported game
|
||||
game_type = getattr(self, '_current_game_type', None)
|
||||
game_name = getattr(self, '_current_game_name', None)
|
||||
@@ -395,9 +489,22 @@ class ProgressHandlersMixin:
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
# --- Create Steam shortcut BEFORE restarting Steam ---
|
||||
# Proceed directly to automated prefix creation
|
||||
self.start_automated_prefix_workflow()
|
||||
if getattr(self, "_is_update_install", False) and getattr(self, "_existing_shortcut_appid", None):
|
||||
# Update workflow: reuse existing shortcut and skip shortcut creation/restart path.
|
||||
modlist_name = self.modlist_name_edit.text().strip()
|
||||
install_dir = os.path.realpath(self.install_dir_edit.text().strip())
|
||||
self._safe_append_text(
|
||||
f"Update mode: reusing existing Steam shortcut AppID {self._existing_shortcut_appid}."
|
||||
)
|
||||
self.continue_configuration_after_automated_prefix(
|
||||
self._existing_shortcut_appid,
|
||||
modlist_name,
|
||||
install_dir,
|
||||
None,
|
||||
)
|
||||
else:
|
||||
# New install workflow: create shortcut and run automated prefix flow.
|
||||
self.start_automated_prefix_workflow()
|
||||
else:
|
||||
# User selected "No" - show completion message and keep GUI open
|
||||
self._safe_append_text("\nModlist installation completed successfully!")
|
||||
@@ -424,6 +531,10 @@ class ProgressHandlersMixin:
|
||||
logger.warning("Install stopped: Nexus Premium required")
|
||||
self._safe_append_text("\nInstall stopped: Nexus Premium required.")
|
||||
self._premium_failure_active = False
|
||||
elif getattr(self, '_installation_cancelled', False):
|
||||
MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low")
|
||||
self._installation_cancelled = False
|
||||
self._cancellation_requested = False
|
||||
elif hasattr(self, '_cancellation_requested') and self._cancellation_requested:
|
||||
# User explicitly cancelled via cancel button
|
||||
MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low")
|
||||
@@ -434,14 +545,39 @@ class ProgressHandlersMixin:
|
||||
if "cancelled by user" in last_output.lower():
|
||||
MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low")
|
||||
else:
|
||||
logger.error(f"Install failed (exit code {exit_code})")
|
||||
engine_error = getattr(self, '_engine_error', None)
|
||||
if engine_error:
|
||||
self._engine_error = None
|
||||
logger.error(
|
||||
"Install failed | exit_code=%s error=%s",
|
||||
exit_code,
|
||||
engine_error.message,
|
||||
)
|
||||
MessageService.show_error(self, engine_error)
|
||||
self._safe_append_text(f"\nInstall failed: {engine_error.message}")
|
||||
else:
|
||||
failure_msg = getattr(self, '_failure_message', None) or f"Exit code {exit_code}."
|
||||
failure_msg = (
|
||||
getattr(self, '_failure_message', None)
|
||||
or "Install failed, but no specific error details were captured from engine output."
|
||||
)
|
||||
self._failure_message = None
|
||||
MessageService.show_error(self, wabbajack_install_failed(failure_msg))
|
||||
self._safe_append_text(f"\nInstall failed (exit code {exit_code}).")
|
||||
logger.error(
|
||||
"Install failed | exit_code=%s summary=%s",
|
||||
exit_code,
|
||||
failure_msg,
|
||||
)
|
||||
MessageService.show_error(
|
||||
self,
|
||||
wabbajack_install_failed(
|
||||
failure_msg,
|
||||
context={
|
||||
"operation": "install_modlist",
|
||||
"step": "engine_install",
|
||||
"exit_code": exit_code,
|
||||
"modlist_name": self.modlist_name_edit.text().strip(),
|
||||
"install_dir": self.install_dir_edit.text().strip(),
|
||||
},
|
||||
),
|
||||
)
|
||||
self._safe_append_text(f"\nInstall failed: {failure_msg}")
|
||||
self.console.moveCursor(QTextCursor.End)
|
||||
|
||||
@@ -1,111 +1,83 @@
|
||||
"""Steam shortcut conflict dialog and retry workflow for InstallModlistScreen (Mixin)."""
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QHBoxLayout,
|
||||
)
|
||||
"""Steam shortcut conflict handling for InstallModlistScreen (Mixin)."""
|
||||
import os
|
||||
|
||||
from jackify.frontends.gui.dialogs.existing_setup_dialog import prompt_existing_setup_dialog
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
|
||||
|
||||
class InstallModlistShortcutDialogMixin:
|
||||
"""Mixin providing shortcut conflict dialog and retry-with-new-name for InstallModlistScreen."""
|
||||
|
||||
def _restore_controls_after_shortcut_dialog_abort(self):
|
||||
"""Return Install Modlist to a usable state when shortcut resolution is aborted."""
|
||||
if hasattr(self, "_abort_install_validation"):
|
||||
try:
|
||||
self._abort_install_validation()
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._enable_controls_after_operation()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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]}'"
|
||||
|
||||
"""Show dialog to resolve existing install / shortcut conflicts."""
|
||||
existing_name = conflicts[0].get("name") or self.modlist_name_edit.text().strip()
|
||||
modlist_name = self.modlist_name_edit.text().strip()
|
||||
install_dir = os.path.realpath(self.install_dir_edit.text().strip())
|
||||
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Steam Shortcut Conflict")
|
||||
dialog.setModal(True)
|
||||
dialog.resize(450, 180)
|
||||
action, new_name = prompt_existing_setup_dialog(
|
||||
self,
|
||||
window_title="Existing Modlist Setup Detected",
|
||||
heading="Modlist Update or New Install",
|
||||
body=(
|
||||
"Jackify detected an existing Steam shortcut for this modlist setup.\n\n"
|
||||
"If you are updating, repairing, or reconfiguring an existing install, choose "
|
||||
"'Use Existing Setup'. If you want a separate Steam entry, enter a different "
|
||||
"name and choose 'Create New Shortcut'."
|
||||
),
|
||||
existing_name=existing_name,
|
||||
requested_name=modlist_name,
|
||||
install_dir=install_dir,
|
||||
field_label="New shortcut name",
|
||||
reuse_label="Use Existing Setup",
|
||||
new_label="Create New Shortcut",
|
||||
cancel_label="Cancel",
|
||||
)
|
||||
|
||||
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;
|
||||
}
|
||||
""")
|
||||
if action == "reuse":
|
||||
existing_appid = conflicts[0].get("appid")
|
||||
if not existing_appid:
|
||||
MessageService.warning(
|
||||
self,
|
||||
"Existing Setup Not Found",
|
||||
"Jackify could not determine the Steam AppID for the existing shortcut.",
|
||||
)
|
||||
self._restore_controls_after_shortcut_dialog_abort()
|
||||
return
|
||||
self._safe_append_text(f"Reusing existing Steam shortcut '{existing_name}'.")
|
||||
self.continue_configuration_after_automated_prefix(int(existing_appid), modlist_name, install_dir, None)
|
||||
return
|
||||
|
||||
layout = QVBoxLayout(dialog)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
layout.setSpacing(15)
|
||||
|
||||
conflict_label = QLabel(f"{conflict_info}\n\nPlease choose a different name for your shortcut:")
|
||||
layout.addWidget(conflict_label)
|
||||
|
||||
name_input = QLineEdit(modlist_name)
|
||||
name_input.selectAll()
|
||||
layout.addWidget(name_input)
|
||||
|
||||
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)
|
||||
|
||||
def on_create():
|
||||
new_name = name_input.text().strip()
|
||||
if action == "new":
|
||||
if new_name and new_name != modlist_name:
|
||||
dialog.accept()
|
||||
self.retry_automated_workflow_with_new_name(new_name)
|
||||
elif new_name == modlist_name:
|
||||
return
|
||||
if new_name == modlist_name:
|
||||
MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.")
|
||||
else:
|
||||
MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.")
|
||||
self._restore_controls_after_shortcut_dialog_abort()
|
||||
return
|
||||
|
||||
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)
|
||||
name_input.returnPressed.connect(on_create)
|
||||
|
||||
dialog.exec()
|
||||
self._safe_append_text("Shortcut creation cancelled by user")
|
||||
self._restore_controls_after_shortcut_dialog_abort()
|
||||
|
||||
def retry_automated_workflow_with_new_name(self, new_name):
|
||||
"""Retry the automated workflow with a new shortcut name."""
|
||||
|
||||
@@ -69,6 +69,11 @@ class InstallModlistUISetupMixin:
|
||||
self.file_progress_list = FileProgressList() # Shows all active files (scrolls if needed)
|
||||
self._premium_notice_shown = False
|
||||
self._premium_failure_active = False
|
||||
self._installation_cancelled = False
|
||||
self._non_premium_gate_enabled = False
|
||||
self._non_premium_info_acknowledged = False
|
||||
self._pending_manual_download_events = None
|
||||
self._non_premium_info_dlg = None
|
||||
self._stalled_download_start_time = None
|
||||
self._stalled_download_notified = False
|
||||
self._stalled_data_snapshot = 0
|
||||
@@ -509,4 +514,3 @@ class InstallModlistUISetupMixin:
|
||||
|
||||
# Now collect all actionable controls after UI is fully built
|
||||
self._collect_actionable_controls()
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""VNV automation methods for InstallModlistScreen (Mixin)."""
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import QTimer
|
||||
"""VNV automation methods for InstallModlistScreen (Mixin).
|
||||
|
||||
Delegates to VNVAutomationController for the actual workflow.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -12,156 +12,28 @@ class VNVAutomationMixin:
|
||||
"""Mixin providing VNV automation methods for InstallModlistScreen."""
|
||||
|
||||
def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str) -> bool:
|
||||
"""Check if VNV automation should run and execute if applicable in background thread
|
||||
|
||||
Args:
|
||||
modlist_name: Name of the installed modlist
|
||||
install_dir: Installation directory path
|
||||
"""Check if VNV automation should run and start it if applicable.
|
||||
|
||||
Returns:
|
||||
True if VNV automation is starting (success dialog should be deferred)
|
||||
False if no VNV automation needed (show success dialog immediately)
|
||||
"""
|
||||
try:
|
||||
from jackify.backend.services.vnv_integration_helper import should_offer_vnv_automation
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
from jackify.backend.services.vnv_post_install_service import VNVPostInstallService
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
from ..services.vnv_automation_controller import VNVAutomationController
|
||||
|
||||
# Get paths first (needed for VNV detection)
|
||||
install_path = Path(install_dir)
|
||||
|
||||
# Quick check before importing more (pass install location for ModOrganizer.ini check)
|
||||
if not should_offer_vnv_automation(modlist_name, install_path):
|
||||
return False
|
||||
|
||||
game_paths = PathHandler().find_vanilla_game_paths()
|
||||
game_root = game_paths.get('Fallout New Vegas')
|
||||
|
||||
if not game_root:
|
||||
logger.debug("DEBUG: VNV automation skipped - FNV game root not found")
|
||||
return False
|
||||
|
||||
# Initialize service to check completion status
|
||||
vnv_service = VNVPostInstallService(
|
||||
modlist_install_location=install_path,
|
||||
game_root=game_root,
|
||||
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path()
|
||||
)
|
||||
|
||||
# Check what's already done
|
||||
completed = vnv_service.check_already_completed()
|
||||
# Only skip if ALL three steps are completed
|
||||
if completed['root_mods'] and completed['4gb_patch'] and completed['bsa_decompressed']:
|
||||
logger.info("VNV automation steps already completed")
|
||||
return False
|
||||
|
||||
# Get automation description for confirmation
|
||||
description = vnv_service.get_automation_description()
|
||||
|
||||
# Show confirmation dialog ON MAIN THREAD (not in worker thread!)
|
||||
from ..services.message_service import MessageService
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
reply = MessageService.question(
|
||||
self,
|
||||
"VNV Post-Install Automation",
|
||||
description,
|
||||
critical=False,
|
||||
safety_level="medium"
|
||||
)
|
||||
|
||||
if reply != QMessageBox.Yes:
|
||||
logger.info("User declined VNV automation")
|
||||
return False
|
||||
|
||||
# Enable post-install progress tracking for VNV automation
|
||||
self._begin_post_install_feedback()
|
||||
|
||||
# User confirmed - start automation in background thread
|
||||
# Note: manual_file_callback is not passed because Qt GUI operations
|
||||
# cannot be called from a background thread. If downloads fail,
|
||||
# the service will return instructions for manual download instead.
|
||||
self._run_vnv_automation_threaded(
|
||||
modlist_name,
|
||||
install_path,
|
||||
game_root
|
||||
)
|
||||
|
||||
return True # VNV automation is running, defer success dialog
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"ERROR: Failed to start VNV automation: {e}")
|
||||
import traceback
|
||||
logger.debug(f"Traceback: {traceback.format_exc()}")
|
||||
return False # Error - show success dialog anyway
|
||||
|
||||
def _run_vnv_automation_threaded(self, modlist_name, install_path, game_root):
|
||||
"""Run VNV automation in a background thread with progress updates
|
||||
|
||||
Note: User confirmation should already be obtained before calling this method.
|
||||
Manual file selection is not supported from background threads - if downloads
|
||||
fail, the service will return instructions for manual download.
|
||||
"""
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
|
||||
class VNVAutomationWorker(QThread):
|
||||
progress_update = Signal(str)
|
||||
completed = Signal(bool, str) # (success, error_message)
|
||||
|
||||
def __init__(self, modlist_name, install_path, game_root, ttw_installer_path):
|
||||
super().__init__()
|
||||
self.modlist_name = modlist_name
|
||||
self.install_path = install_path
|
||||
self.game_root = game_root
|
||||
self.ttw_installer_path = ttw_installer_path
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
# User already confirmed, pass lambda that always returns True
|
||||
# manual_file_callback is None - downloads that fail will return
|
||||
# instructions for manual download instead of showing Qt dialogs
|
||||
automation_ran, error = run_vnv_automation_if_applicable(
|
||||
modlist_name=self.modlist_name,
|
||||
modlist_install_location=self.install_path,
|
||||
game_root=self.game_root,
|
||||
ttw_installer_path=self.ttw_installer_path,
|
||||
progress_callback=self.progress_update.emit,
|
||||
manual_file_callback=None,
|
||||
confirmation_callback=lambda desc: True # Already confirmed on main thread
|
||||
)
|
||||
self.completed.emit(error is None, error or "")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self.completed.emit(False, f"Exception: {str(e)}\n{traceback.format_exc()}")
|
||||
|
||||
# Create and start worker
|
||||
self.vnv_worker = VNVAutomationWorker(
|
||||
modlist_name,
|
||||
install_path,
|
||||
game_root,
|
||||
AutomatedPrefixService.get_ttw_installer_path()
|
||||
self._vnv_controller = VNVAutomationController()
|
||||
return self._vnv_controller.attempt(
|
||||
parent=self,
|
||||
modlist_name=modlist_name,
|
||||
install_dir=install_dir,
|
||||
on_progress=self._safe_append_text,
|
||||
on_complete=self._on_vnv_complete,
|
||||
begin_feedback=self._begin_post_install_feedback,
|
||||
handle_feedback=self._handle_post_install_progress,
|
||||
)
|
||||
|
||||
# Connect signals
|
||||
self.vnv_worker.progress_update.connect(self._on_vnv_progress)
|
||||
self.vnv_worker.completed.connect(self._on_vnv_complete)
|
||||
self.vnv_worker.finished.connect(self.vnv_worker.deleteLater)
|
||||
|
||||
# Start worker
|
||||
self.vnv_worker.start()
|
||||
|
||||
def _on_vnv_progress(self, message: str):
|
||||
"""Handle VNV automation progress updates"""
|
||||
self._safe_append_text(message)
|
||||
# Also update progress indicator, Activity window, and Details window
|
||||
self._handle_post_install_progress(message)
|
||||
|
||||
def _on_vnv_complete(self, success: bool, error: str):
|
||||
"""Handle VNV automation completion and show deferred success dialog"""
|
||||
# End post-install feedback now that VNV automation is complete
|
||||
self._end_post_install_feedback(True)
|
||||
"""Handle VNV automation completion and show deferred success dialog."""
|
||||
self._end_post_install_feedback(not bool(error))
|
||||
|
||||
if not success and error:
|
||||
from ..services.message_service import MessageService
|
||||
@@ -175,32 +47,26 @@ class VNVAutomationMixin:
|
||||
elif success:
|
||||
self._safe_append_text("VNV post-install automation completed successfully")
|
||||
|
||||
# Show the deferred success dialog now that VNV automation is complete
|
||||
if hasattr(self, '_pending_success_dialog_params'):
|
||||
params = self._pending_success_dialog_params
|
||||
del self._pending_success_dialog_params # Clean up
|
||||
del self._pending_success_dialog_params
|
||||
|
||||
# Clear Activity window before showing success dialog
|
||||
self.file_progress_list.clear()
|
||||
|
||||
# Show success dialog
|
||||
from ..dialogs import SuccessDialog
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=params['modlist_name'],
|
||||
workflow_type="install",
|
||||
time_taken=params['time_taken'],
|
||||
game_name=params['game_name'],
|
||||
parent=self
|
||||
parent=self,
|
||||
)
|
||||
success_dialog.show()
|
||||
|
||||
# Show ENB Proton dialog if ENB was detected
|
||||
if params.get('enb_detected'):
|
||||
try:
|
||||
from ..dialogs.enb_proton_dialog import ENBProtonDialog
|
||||
enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self)
|
||||
enb_dialog.exec() # Modal dialog - blocks until user clicks OK
|
||||
enb_dialog.exec()
|
||||
except Exception as e:
|
||||
# Non-blocking: if dialog fails, just log and continue
|
||||
logger.warning(f"Failed to show ENB dialog: {e}")
|
||||
|
||||
logger.warning("Failed to show ENB dialog: %s", e)
|
||||
|
||||
@@ -1,359 +1,367 @@
|
||||
"""Installation workflow methods for InstallModlistScreen (Mixin)."""
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
|
||||
from .install_modlist_installer_thread import InstallerThread
|
||||
from jackify.frontends.gui.dialogs.existing_setup_dialog import prompt_existing_setup_dialog
|
||||
from .install_modlist_output_mixin import InstallModlistOutputMixin
|
||||
from jackify.backend.services.steam_restart_service import ensure_flatpak_steam_filesystem_access
|
||||
from jackify.shared.errors import install_dir_create_failed
|
||||
from .install_modlist_workflow_execution import InstallWorkflowExecutionMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InstallWorkflowMixin(InstallModlistOutputMixin):
|
||||
class InstallWorkflowMixin(InstallWorkflowExecutionMixin, InstallModlistOutputMixin):
|
||||
"""Mixin providing installation workflow methods for InstallModlistScreen."""
|
||||
|
||||
def validate_and_start_install(self):
|
||||
import time
|
||||
self._install_workflow_start_time = time.time()
|
||||
logger.debug('DEBUG: validate_and_start_install called')
|
||||
@staticmethod
|
||||
def _normalize_version_token(value: str | None) -> str | None:
|
||||
"""Return a normalized version token for lightweight equality checks."""
|
||||
if value is None:
|
||||
return None
|
||||
token = str(value).strip()
|
||||
if not token:
|
||||
return None
|
||||
token = token.lstrip("vV")
|
||||
return token.lower()
|
||||
|
||||
# Immediately show "Initialising" status to provide feedback
|
||||
self.progress_indicator.set_status("Initialising...", 0)
|
||||
from PySide6.QtWidgets import QApplication
|
||||
QApplication.processEvents() # Force UI update
|
||||
@staticmethod
|
||||
def _normalize_modlist_name(value: str | None) -> str:
|
||||
return " ".join((value or "").strip().lower().split())
|
||||
|
||||
# Reload config to pick up any settings changes made in Settings dialog
|
||||
self.config_handler.reload_config()
|
||||
def _get_requested_modlist_version(self, install_mode: str) -> str | None:
|
||||
"""Return selected modlist version from gallery metadata when available."""
|
||||
if install_mode != "online":
|
||||
return None
|
||||
info = getattr(self, "selected_modlist_info", None) or {}
|
||||
return self._normalize_version_token(info.get("version"))
|
||||
|
||||
# Check protontricks before proceeding
|
||||
if not self._check_protontricks():
|
||||
self.progress_indicator.reset()
|
||||
def _evaluate_update_candidate(
|
||||
self,
|
||||
modlist_name: str,
|
||||
install_dir: str,
|
||||
install_mode: str,
|
||||
existing_appid: str | None,
|
||||
) -> tuple[bool, dict]:
|
||||
"""
|
||||
Decide whether update-mode prompt should be shown.
|
||||
|
||||
Policy:
|
||||
- Require existing shortcut AppID and jackify_meta.json.
|
||||
- Require modlist identity match (requested name == installed meta name).
|
||||
- Version relation is informational:
|
||||
- `different` when both requested/installed versions are available and differ.
|
||||
- `same` when both are available and equal.
|
||||
- `unknown` when either side is missing.
|
||||
"""
|
||||
from jackify.backend.utils.modlist_meta import read_modlist_meta
|
||||
|
||||
result = {
|
||||
"eligible": False,
|
||||
"reason": "unknown",
|
||||
"requested_version": None,
|
||||
"installed_version": None,
|
||||
"version_relation": "unknown",
|
||||
"installed_name": None,
|
||||
}
|
||||
if not existing_appid:
|
||||
result["reason"] = "missing_shortcut_appid"
|
||||
return False, result
|
||||
|
||||
meta = read_modlist_meta(install_dir)
|
||||
if not meta:
|
||||
result["reason"] = "missing_meta"
|
||||
return False, result
|
||||
|
||||
installed_name = (meta.get("modlist_name") or "").strip()
|
||||
result["installed_name"] = installed_name
|
||||
if self._normalize_modlist_name(installed_name) != self._normalize_modlist_name(modlist_name):
|
||||
result["reason"] = "modlist_name_mismatch"
|
||||
return False, result
|
||||
|
||||
requested_version = self._get_requested_modlist_version(install_mode)
|
||||
installed_version = self._normalize_version_token(meta.get("modlist_version"))
|
||||
result["requested_version"] = requested_version
|
||||
result["installed_version"] = installed_version
|
||||
if requested_version and installed_version:
|
||||
result["version_relation"] = "same" if requested_version == installed_version else "different"
|
||||
|
||||
result["eligible"] = True
|
||||
result["reason"] = "eligible"
|
||||
return True, result
|
||||
|
||||
def _resolve_modorganizer_ini_path(self, install_dir: str) -> str | None:
|
||||
"""Return ModOrganizer.ini path for standard/special layouts."""
|
||||
candidates = [
|
||||
os.path.join(install_dir, "ModOrganizer.ini"),
|
||||
os.path.join(install_dir, "files", "ModOrganizer.ini"),
|
||||
]
|
||||
for candidate in candidates:
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def _capture_mo2_path_state(self, ini_path: str) -> dict[str, str]:
|
||||
"""Capture path-critical keys from ModOrganizer.ini for update comparison."""
|
||||
state: dict[str, str] = {}
|
||||
section = "root"
|
||||
try:
|
||||
with open(ini_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
for raw_line in f:
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith(("#", ";")):
|
||||
continue
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
section = line[1:-1].strip() or "root"
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
key_lower = key.lower()
|
||||
if (
|
||||
key_lower in {"gamepath", "download_directory"}
|
||||
or key_lower.startswith("binary")
|
||||
or key_lower.startswith("workingdirectory")
|
||||
):
|
||||
state[f"{section}.{key}"] = value
|
||||
except Exception as e:
|
||||
logger.warning("Failed to capture MO2 path state from %s: %s", ini_path, e)
|
||||
return state
|
||||
|
||||
def _create_update_ini_backup(self, ini_path: str, label: str) -> str | None:
|
||||
"""Create timestamped backup of ModOrganizer.ini for update traceability."""
|
||||
try:
|
||||
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
||||
backup_path = f"{ini_path}.{label}_{timestamp}.bak"
|
||||
shutil.copy2(ini_path, backup_path)
|
||||
return backup_path
|
||||
except Exception as e:
|
||||
logger.warning("Failed to create %s backup for %s: %s", label, ini_path, e)
|
||||
return None
|
||||
|
||||
def _record_pre_update_ini_snapshot(self, install_dir: str) -> None:
|
||||
"""Capture pre-engine MO2 ini snapshot/backup for update-mode comparison."""
|
||||
ini_path = self._resolve_modorganizer_ini_path(install_dir)
|
||||
if not ini_path:
|
||||
self._update_pre_engine_ini_path = None
|
||||
self._update_pre_engine_ini_state = {}
|
||||
logger.warning("Update mode: ModOrganizer.ini not found before engine phase")
|
||||
return
|
||||
|
||||
# Disable all controls during installation (except Cancel)
|
||||
self._disable_controls_during_operation()
|
||||
|
||||
try:
|
||||
tab_index = self.source_tabs.currentIndex()
|
||||
install_mode = 'online'
|
||||
if tab_index == 1: # .wabbajack File tab
|
||||
modlist = self.file_edit.text().strip()
|
||||
if not modlist or not os.path.isfile(modlist) or not modlist.endswith('.wabbajack'):
|
||||
self._abort_with_message(
|
||||
"warning",
|
||||
"Invalid Modlist",
|
||||
"Please select a valid .wabbajack file."
|
||||
)
|
||||
return
|
||||
install_mode = 'file'
|
||||
else:
|
||||
# For online modlists, ALWAYS use machine_url from selected_modlist_info
|
||||
# Button text is now the display name (title), NOT the machine URL
|
||||
if not hasattr(self, 'selected_modlist_info') or not self.selected_modlist_info:
|
||||
self._abort_with_message(
|
||||
"warning",
|
||||
"Invalid Modlist",
|
||||
"Modlist information is missing. Please select the modlist again from the gallery."
|
||||
)
|
||||
return
|
||||
|
||||
machine_url = self.selected_modlist_info.get('machine_url')
|
||||
if not machine_url:
|
||||
self._abort_with_message(
|
||||
"warning",
|
||||
"Invalid Modlist",
|
||||
"Modlist information is incomplete. Please select the modlist again from the gallery."
|
||||
)
|
||||
return
|
||||
|
||||
# CRITICAL: Use machine_url, NOT button text
|
||||
modlist = machine_url
|
||||
install_dir = self.install_dir_edit.text().strip()
|
||||
downloads_dir = self.downloads_dir_edit.text().strip()
|
||||
|
||||
# Get authentication token (OAuth or API key) with automatic refresh
|
||||
api_key, oauth_info = self.auth_service.get_auth_for_engine()
|
||||
if not api_key:
|
||||
self._abort_with_message(
|
||||
"warning",
|
||||
"Authorisation Required",
|
||||
"Please authorise with Nexus Mods before installing modlists.\n\n"
|
||||
"Click the 'Authorise' button above to log in with OAuth,\n"
|
||||
"or configure an API key in Settings.",
|
||||
safety_level="medium"
|
||||
)
|
||||
return
|
||||
|
||||
# Log authentication status at install start (Issue #111 diagnostics)
|
||||
auth_method = self.auth_service.get_auth_method()
|
||||
logger.info("=" * 60)
|
||||
logger.info("Authentication Status at Install Start")
|
||||
logger.info(f"Method: {auth_method or 'UNKNOWN'}")
|
||||
logger.info(f"Token length: {len(api_key)} chars")
|
||||
if len(api_key) >= 8:
|
||||
logger.info(f"Token (partial): {api_key[:4]}...{api_key[-4:]}")
|
||||
|
||||
if auth_method == 'oauth':
|
||||
token_handler = self.auth_service.token_handler
|
||||
token_info = token_handler.get_token_info()
|
||||
if 'expires_in_minutes' in token_info:
|
||||
logger.info(f"OAuth expires in: {token_info['expires_in_minutes']:.1f} minutes")
|
||||
if token_info.get('refresh_token_likely_expired'):
|
||||
logger.warning(f"OAuth refresh token age: {token_info['refresh_token_age_days']:.1f} days (may need re-auth)")
|
||||
logger.info("=" * 60)
|
||||
|
||||
modlist_name = self.modlist_name_edit.text().strip()
|
||||
missing_fields = []
|
||||
if not modlist_name:
|
||||
missing_fields.append("Modlist Name")
|
||||
if not install_dir:
|
||||
missing_fields.append("Install Directory")
|
||||
if not downloads_dir:
|
||||
missing_fields.append("Downloads Directory")
|
||||
if missing_fields:
|
||||
self._abort_with_message(
|
||||
"warning",
|
||||
"Missing Required Fields",
|
||||
"Please fill in all required fields before starting the install:\n- " + "\n- ".join(missing_fields)
|
||||
)
|
||||
return
|
||||
from jackify.backend.handlers.validation_handler import ValidationHandler
|
||||
validation_handler = ValidationHandler()
|
||||
is_safe, reason = validation_handler.is_safe_install_directory(Path(install_dir))
|
||||
if not is_safe:
|
||||
from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog
|
||||
dlg = WarningDialog(reason, parent=self)
|
||||
result = dlg.exec()
|
||||
if not result or not dlg.confirmed:
|
||||
self._abort_install_validation()
|
||||
return
|
||||
if not os.path.isdir(install_dir):
|
||||
from ..services.message_service import MessageService
|
||||
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.show_error(self, install_dir_create_failed(install_dir, str(e)))
|
||||
self._abort_install_validation()
|
||||
return
|
||||
else:
|
||||
self._abort_install_validation()
|
||||
return
|
||||
if not os.path.isdir(downloads_dir):
|
||||
from ..services.message_service import MessageService
|
||||
create = MessageService.question(self, "Create Directory?",
|
||||
f"The downloads directory does not exist:\n{downloads_dir}\n\nWould you like to create it?",
|
||||
critical=False # Non-critical, won't steal focus
|
||||
)
|
||||
if create == QMessageBox.Yes:
|
||||
try:
|
||||
os.makedirs(downloads_dir, exist_ok=True)
|
||||
except Exception as e:
|
||||
MessageService.show_error(self, install_dir_create_failed(downloads_dir, str(e)))
|
||||
self._abort_install_validation()
|
||||
return
|
||||
else:
|
||||
self._abort_install_validation()
|
||||
return
|
||||
|
||||
# Handle resolution saving
|
||||
resolution = self.resolution_combo.currentText()
|
||||
if resolution and resolution != "Leave unchanged":
|
||||
success = self.resolution_service.save_resolution(resolution)
|
||||
if success:
|
||||
logger.debug(f"DEBUG: Resolution saved successfully: {resolution}")
|
||||
else:
|
||||
logger.debug("DEBUG: Failed to save resolution")
|
||||
else:
|
||||
# Clear saved resolution if "Leave unchanged" is selected
|
||||
if self.resolution_service.has_saved_resolution():
|
||||
self.resolution_service.clear_saved_resolution()
|
||||
logger.debug("DEBUG: Saved resolution cleared")
|
||||
|
||||
ensure_flatpak_steam_filesystem_access(Path(install_dir))
|
||||
|
||||
# Handle parent directory saving
|
||||
self._save_parent_directories(install_dir, downloads_dir)
|
||||
|
||||
# Detect game type and check support
|
||||
game_type = None
|
||||
game_name = None
|
||||
|
||||
if install_mode == 'file':
|
||||
# Parse .wabbajack file to get game type
|
||||
wabbajack_path = Path(modlist)
|
||||
result = self.wabbajack_parser.parse_wabbajack_game_type(wabbajack_path)
|
||||
if result:
|
||||
if isinstance(result, tuple):
|
||||
game_type, raw_game_type = result
|
||||
# Get display name for the game
|
||||
display_names = {
|
||||
'skyrim': 'Skyrim',
|
||||
'fallout4': 'Fallout 4',
|
||||
'falloutnv': 'Fallout New Vegas',
|
||||
'oblivion': 'Oblivion',
|
||||
'starfield': 'Starfield',
|
||||
'oblivion_remastered': 'Oblivion Remastered',
|
||||
'enderal': 'Enderal'
|
||||
}
|
||||
if game_type == 'unknown' and raw_game_type:
|
||||
game_name = raw_game_type
|
||||
else:
|
||||
game_name = display_names.get(game_type, game_type)
|
||||
else:
|
||||
game_type = result
|
||||
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(game_type, game_type)
|
||||
else:
|
||||
# For online modlists, try to get game type from selected modlist
|
||||
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
|
||||
game_name = self.selected_modlist_info.get('game', '')
|
||||
logger.debug(f"DEBUG: Detected game_name from selected_modlist_info: '{game_name}'")
|
||||
|
||||
# Map game name to game type
|
||||
game_mapping = {
|
||||
'skyrim special edition': 'skyrim',
|
||||
'skyrim': 'skyrim',
|
||||
'fallout 4': 'fallout4',
|
||||
'fallout new vegas': 'falloutnv',
|
||||
'oblivion': 'oblivion',
|
||||
'starfield': 'starfield',
|
||||
'oblivion_remastered': 'oblivion_remastered',
|
||||
'enderal': 'enderal',
|
||||
'enderal special edition': 'enderal'
|
||||
}
|
||||
game_type = game_mapping.get(game_name.lower())
|
||||
logger.debug(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'")
|
||||
if not game_type:
|
||||
game_type = 'unknown'
|
||||
logger.debug(f"DEBUG: Game type not found in mapping, setting to 'unknown'")
|
||||
else:
|
||||
logger.debug(f"DEBUG: No selected_modlist_info found")
|
||||
game_type = 'unknown'
|
||||
|
||||
# Store game type and name for later use
|
||||
self._current_game_type = game_type
|
||||
self._current_game_name = game_name
|
||||
|
||||
# Check if game is supported
|
||||
logger.debug(f"DEBUG: Checking if game_type '{game_type}' is supported")
|
||||
logger.debug(f"DEBUG: game_type='{game_type}', game_name='{game_name}'")
|
||||
is_supported = self.wabbajack_parser.is_supported_game(game_type) if game_type else False
|
||||
logger.debug(f"DEBUG: is_supported_game('{game_type}') returned: {is_supported}")
|
||||
|
||||
if game_type and not is_supported:
|
||||
logger.debug(f"DEBUG: Game '{game_type}' is not supported, showing dialog")
|
||||
# Show unsupported game dialog
|
||||
from ..widgets.unsupported_game_dialog import UnsupportedGameDialog
|
||||
dialog = UnsupportedGameDialog(self, game_name)
|
||||
if not dialog.show_dialog(self, game_name):
|
||||
self._abort_install_validation()
|
||||
return
|
||||
|
||||
self.console.clear()
|
||||
self.process_monitor.clear()
|
||||
|
||||
# R&D: Reset progress indicator for new installation
|
||||
self.progress_indicator.reset()
|
||||
self.progress_state_manager.reset()
|
||||
self.file_progress_list.clear()
|
||||
self.file_progress_list.start_cpu_tracking() # Start tracking CPU during installation
|
||||
self._premium_notice_shown = False
|
||||
self._stalled_download_start_time = None
|
||||
self._stalled_download_notified = False
|
||||
self._stalled_data_snapshot = 0
|
||||
self._token_error_notified = False # Reset token error notification
|
||||
self._premium_failure_active = False
|
||||
self._post_install_active = False
|
||||
self._post_install_current_step = 0
|
||||
# Activity tab is always visible (tabs handle visibility automatically)
|
||||
|
||||
# Update button states for installation
|
||||
self.start_btn.setEnabled(False)
|
||||
self.cancel_btn.setVisible(False)
|
||||
self.cancel_install_btn.setVisible(True)
|
||||
|
||||
# CRITICAL: Final safety check - ensure online modlists use machine_url
|
||||
if install_mode == 'online':
|
||||
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
|
||||
expected_machine_url = self.selected_modlist_info.get('machine_url')
|
||||
if expected_machine_url:
|
||||
modlist = expected_machine_url # Force use machine_url
|
||||
else:
|
||||
self._abort_with_message(
|
||||
"critical",
|
||||
"Installation Error",
|
||||
"Cannot determine modlist machine URL. Please select the modlist again."
|
||||
)
|
||||
return
|
||||
else:
|
||||
self._abort_with_message(
|
||||
"critical",
|
||||
"Installation Error",
|
||||
"Modlist information is missing. Please select the modlist again from the gallery."
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug(f'DEBUG: Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, install_mode={install_mode}')
|
||||
self.run_modlist_installer(modlist, install_dir, downloads_dir, api_key, install_mode, oauth_info)
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Exception in validate_and_start_install: {e}")
|
||||
import traceback
|
||||
logger.debug(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)
|
||||
logger.debug(f"DEBUG: Controls re-enabled in exception handler")
|
||||
|
||||
def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online', oauth_info=None):
|
||||
logger.debug('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER')
|
||||
|
||||
# Rotate log file at start of each workflow run (keep 5 backups)
|
||||
from jackify.backend.handlers.logging_handler import LoggingHandler
|
||||
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()
|
||||
from jackify import __version__ as jackify_version
|
||||
self._safe_append_text(f"Jackify v{jackify_version}")
|
||||
self._safe_append_text("Starting modlist installation with custom progress handling...")
|
||||
|
||||
# Update UI state for installation
|
||||
self.start_btn.setEnabled(False)
|
||||
self.cancel_btn.setVisible(False)
|
||||
self.cancel_install_btn.setVisible(True)
|
||||
|
||||
self.install_thread = InstallerThread(
|
||||
modlist, install_dir, downloads_dir, api_key, self.modlist_name_edit.text().strip(), install_mode,
|
||||
progress_state_manager=self.progress_state_manager, # R&D: Pass progress state manager
|
||||
auth_service=self.auth_service, # Fix Issue #127: Pass auth_service for Premium detection diagnostics
|
||||
oauth_info=oauth_info # Pass OAuth state for auto-refresh
|
||||
self._update_pre_engine_ini_path = ini_path
|
||||
self._update_pre_engine_ini_state = self._capture_mo2_path_state(ini_path)
|
||||
self._update_pre_engine_ini_backup = self._create_update_ini_backup(ini_path, "pre_update")
|
||||
logger.info(
|
||||
"Update mode: captured pre-engine MO2 state | ini=%s backup=%s keys=%d",
|
||||
ini_path,
|
||||
self._update_pre_engine_ini_backup,
|
||||
len(self._update_pre_engine_ini_state),
|
||||
)
|
||||
self.install_thread.output_received.connect(self.on_installation_output)
|
||||
self.install_thread.progress_received.connect(self.on_installation_progress)
|
||||
self.install_thread.progress_updated.connect(self.on_progress_updated) # R&D: Connect progress update
|
||||
self.install_thread.installation_finished.connect(self.on_installation_finished)
|
||||
self.install_thread.premium_required_detected.connect(self.on_premium_required_detected)
|
||||
# R&D: Pass progress state manager to thread
|
||||
self.install_thread.progress_state_manager = self.progress_state_manager
|
||||
self.install_thread.start()
|
||||
|
||||
def _record_post_engine_ini_snapshot_and_diff(self, install_dir: str) -> None:
|
||||
"""Capture post-engine MO2 snapshot and log path-key drift vs pre-engine state."""
|
||||
ini_path = self._resolve_modorganizer_ini_path(install_dir)
|
||||
if not ini_path:
|
||||
logger.warning("Update mode: ModOrganizer.ini not found after engine phase")
|
||||
return
|
||||
|
||||
post_state = self._capture_mo2_path_state(ini_path)
|
||||
post_backup = self._create_update_ini_backup(ini_path, "post_engine")
|
||||
pre_state = getattr(self, "_update_pre_engine_ini_state", {}) or {}
|
||||
|
||||
changed: list[str] = []
|
||||
for key in sorted(set(pre_state) | set(post_state)):
|
||||
before = pre_state.get(key)
|
||||
after = post_state.get(key)
|
||||
if before != after:
|
||||
changed.append(f"{key}: '{before}' -> '{after}'")
|
||||
|
||||
self._update_ini_path_drift_detected = bool(changed)
|
||||
self._update_post_engine_ini_state = post_state
|
||||
self._update_post_engine_ini_path = ini_path
|
||||
logger.info(
|
||||
"Update mode: captured post-engine MO2 state | ini=%s backup=%s keys=%d changed=%d",
|
||||
ini_path,
|
||||
post_backup,
|
||||
len(post_state),
|
||||
len(changed),
|
||||
)
|
||||
if changed:
|
||||
logger.warning("Update mode: MO2 path-key changes detected after engine phase")
|
||||
for change in changed:
|
||||
logger.warning("Update mode INI diff | %s", change)
|
||||
else:
|
||||
logger.info("Update mode: no path-key changes detected in ModOrganizer.ini after engine phase")
|
||||
|
||||
def _verify_update_ini_after_configuration(self, install_dir: str) -> None:
|
||||
"""Log-only verification of path-critical ModOrganizer.ini keys after update configuration."""
|
||||
summary = self._evaluate_update_ini_verification(install_dir)
|
||||
if not summary.get("ini_found"):
|
||||
logger.warning("Update mode verify: ModOrganizer.ini not found after configuration")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Update mode verify: MO2 ini post-config summary | ini=%s critical_keys=%d empty_critical=%d changed_vs_post_engine=%d changed_vs_pre_engine=%d",
|
||||
summary["ini_path"],
|
||||
summary["critical_key_count"],
|
||||
summary["empty_critical_count"],
|
||||
summary["changed_vs_post_engine_count"],
|
||||
summary["changed_vs_pre_engine_count"],
|
||||
)
|
||||
if summary["empty_critical_keys"]:
|
||||
logger.warning("Update mode verify: empty critical MO2 keys detected")
|
||||
for key in summary["empty_critical_keys"]:
|
||||
logger.warning("Update mode verify | empty key: %s", key)
|
||||
|
||||
def _evaluate_update_ini_verification(self, install_dir: str) -> dict:
|
||||
"""
|
||||
Evaluate post-config MO2 path-key integrity for update-mode installs.
|
||||
|
||||
Returns a summary dictionary that can be consumed by logging or tests.
|
||||
"""
|
||||
ini_path = self._resolve_modorganizer_ini_path(install_dir)
|
||||
if not ini_path:
|
||||
return {
|
||||
"ini_found": False,
|
||||
"ini_path": None,
|
||||
"critical_key_count": 0,
|
||||
"empty_critical_count": 0,
|
||||
"empty_critical_keys": [],
|
||||
"changed_vs_post_engine_count": 0,
|
||||
"changed_vs_pre_engine_count": 0,
|
||||
"changed_vs_post_engine_keys": [],
|
||||
"changed_vs_pre_engine_keys": [],
|
||||
}
|
||||
|
||||
final_state = self._capture_mo2_path_state(ini_path)
|
||||
pre_state = getattr(self, "_update_pre_engine_ini_state", {}) or {}
|
||||
post_engine_state = getattr(self, "_update_post_engine_ini_state", {}) or {}
|
||||
|
||||
critical_items = {
|
||||
k: v
|
||||
for k, v in final_state.items()
|
||||
if (
|
||||
k.lower().endswith(".gamepath")
|
||||
or ".binary" in k.lower()
|
||||
or ".workingdirectory" in k.lower()
|
||||
or k.lower().endswith(".download_directory")
|
||||
)
|
||||
}
|
||||
empty_critical = [k for k, v in critical_items.items() if not (v or "").strip()]
|
||||
|
||||
changed_vs_post_engine = [
|
||||
k
|
||||
for k in sorted(set(post_engine_state) | set(final_state))
|
||||
if post_engine_state.get(k) != final_state.get(k)
|
||||
]
|
||||
changed_vs_pre_engine = [
|
||||
k
|
||||
for k in sorted(set(pre_state) | set(final_state))
|
||||
if pre_state.get(k) != final_state.get(k)
|
||||
]
|
||||
return {
|
||||
"ini_found": True,
|
||||
"ini_path": ini_path,
|
||||
"critical_key_count": len(critical_items),
|
||||
"empty_critical_count": len(empty_critical),
|
||||
"empty_critical_keys": empty_critical,
|
||||
"changed_vs_post_engine_count": len(changed_vs_post_engine),
|
||||
"changed_vs_pre_engine_count": len(changed_vs_pre_engine),
|
||||
"changed_vs_post_engine_keys": changed_vs_post_engine,
|
||||
"changed_vs_pre_engine_keys": changed_vs_pre_engine,
|
||||
}
|
||||
|
||||
def _find_existing_shortcut_appid(self, modlist_name: str, install_dir: str) -> str | None:
|
||||
"""Return existing Steam shortcut AppID for this install dir/name when present."""
|
||||
try:
|
||||
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, verbose=False)
|
||||
|
||||
install_real = os.path.realpath(install_dir)
|
||||
candidate_exes = [
|
||||
os.path.join(install_real, "ModOrganizer.exe"),
|
||||
os.path.join(install_real, "files", "ModOrganizer.exe"), # Somnium layout
|
||||
]
|
||||
|
||||
for exe_path in candidate_exes:
|
||||
if not os.path.exists(exe_path):
|
||||
continue
|
||||
appid = shortcut_handler.get_appid_from_vdf(modlist_name, exe_path)
|
||||
if appid:
|
||||
return appid
|
||||
|
||||
# Fallback: match by name + start dir from shortcuts.vdf even if exe moved
|
||||
for shortcut in shortcut_handler.find_shortcuts_by_exe("ModOrganizer.exe"):
|
||||
if (
|
||||
(shortcut.get("AppName", "").strip() == modlist_name.strip())
|
||||
and os.path.realpath(shortcut.get("StartDir", "")) == install_real
|
||||
):
|
||||
raw_appid = shortcut.get("appid")
|
||||
if raw_appid is not None:
|
||||
return str(int(raw_appid) & 0xFFFFFFFF)
|
||||
except Exception as e:
|
||||
logger.warning("Update detection: failed shortcut lookup: %s", e)
|
||||
return None
|
||||
|
||||
def _prompt_update_or_new_install(
|
||||
self,
|
||||
modlist_name: str,
|
||||
install_dir: str,
|
||||
update_meta: dict | None = None,
|
||||
) -> str:
|
||||
"""Prompt user when update conditions are met. Returns: 'update'|'new'|'cancel'."""
|
||||
version_note = ""
|
||||
if update_meta:
|
||||
relation = update_meta.get("version_relation")
|
||||
req = update_meta.get("requested_version")
|
||||
inst = update_meta.get("installed_version")
|
||||
if relation == "different":
|
||||
version_note = (
|
||||
f"\n\nDetected version change: installed v{inst} -> selected v{req}."
|
||||
)
|
||||
elif relation == "same" and inst:
|
||||
version_note = (
|
||||
f"\n\nDetected same version (v{inst}). "
|
||||
"Use the existing setup if you are repairing or reconfiguring this install."
|
||||
)
|
||||
|
||||
body = (
|
||||
"Jackify detected an existing modlist installation in the selected directory.\n\n"
|
||||
"Choose 'Use Existing Setup' to continue with the current install and Steam shortcut. "
|
||||
"Choose 'Create New Shortcut' only if you want a separate Steam entry with a different name."
|
||||
f"{version_note}"
|
||||
)
|
||||
|
||||
action, new_name = prompt_existing_setup_dialog(
|
||||
self,
|
||||
window_title="Existing Modlist Setup Detected",
|
||||
heading="Use Existing Setup or Create a New Shortcut",
|
||||
body=body,
|
||||
existing_name=modlist_name,
|
||||
requested_name=modlist_name,
|
||||
install_dir=install_dir,
|
||||
field_label="New shortcut name",
|
||||
reuse_label="Use Existing Setup",
|
||||
new_label="Create New Shortcut",
|
||||
cancel_label="Cancel",
|
||||
)
|
||||
|
||||
if action == "reuse":
|
||||
return "update"
|
||||
if action == "new":
|
||||
if not new_name:
|
||||
MessageBox = QMessageBox # keep local usage explicit
|
||||
MessageBox.warning(self, "Invalid Name", "Please enter a valid shortcut name.")
|
||||
return "cancel"
|
||||
if new_name == modlist_name:
|
||||
QMessageBox.warning(self, "Same Name", "Please enter a different name to create a separate shortcut.")
|
||||
return "cancel"
|
||||
self.modlist_name_edit.setText(new_name)
|
||||
return "new"
|
||||
return "cancel"
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
"""Execution workflow methods for InstallModlistScreen (Mixin)."""
|
||||
|
||||
from pathlib import Path
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
import logging
|
||||
import os
|
||||
|
||||
from .install_modlist_installer_thread import InstallerThread
|
||||
from jackify.backend.services.steam_restart_service import ensure_flatpak_steam_filesystem_access
|
||||
from jackify.shared.errors import install_dir_create_failed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InstallWorkflowExecutionMixin:
|
||||
"""Mixin containing install-run and manual-download dialog execution methods."""
|
||||
def validate_and_start_install(self):
|
||||
import time
|
||||
self._install_workflow_start_time = time.time()
|
||||
logger.debug('DEBUG: validate_and_start_install called')
|
||||
|
||||
# Immediately show "Initialising" status to provide feedback
|
||||
self.progress_indicator.set_status("Initialising...", 0)
|
||||
from PySide6.QtWidgets import QApplication
|
||||
QApplication.processEvents() # Force UI update
|
||||
|
||||
# Reload config to pick up any settings changes made in Settings dialog
|
||||
self.config_handler.reload_config()
|
||||
|
||||
# Check protontricks before proceeding
|
||||
if not self._check_protontricks():
|
||||
self.progress_indicator.reset()
|
||||
return
|
||||
|
||||
# Disable all controls during installation (except Cancel)
|
||||
self._disable_controls_during_operation()
|
||||
|
||||
try:
|
||||
tab_index = self.source_tabs.currentIndex()
|
||||
install_mode = 'online'
|
||||
if tab_index == 1: # .wabbajack File tab
|
||||
modlist = self.file_edit.text().strip()
|
||||
if not modlist or not os.path.isfile(modlist) or not modlist.endswith('.wabbajack'):
|
||||
self._abort_with_message(
|
||||
"warning",
|
||||
"Invalid Modlist",
|
||||
"Please select a valid .wabbajack file."
|
||||
)
|
||||
return
|
||||
install_mode = 'file'
|
||||
else:
|
||||
# For online modlists, ALWAYS use machine_url from selected_modlist_info
|
||||
# Button text is now the display name (title), NOT the machine URL
|
||||
if not hasattr(self, 'selected_modlist_info') or not self.selected_modlist_info:
|
||||
self._abort_with_message(
|
||||
"warning",
|
||||
"Invalid Modlist",
|
||||
"Modlist information is missing. Please select the modlist again from the gallery."
|
||||
)
|
||||
return
|
||||
|
||||
machine_url = self.selected_modlist_info.get('machine_url')
|
||||
if not machine_url:
|
||||
self._abort_with_message(
|
||||
"warning",
|
||||
"Invalid Modlist",
|
||||
"Modlist information is incomplete. Please select the modlist again from the gallery."
|
||||
)
|
||||
return
|
||||
|
||||
# CRITICAL: Use machine_url, NOT button text
|
||||
modlist = machine_url
|
||||
install_dir = self.install_dir_edit.text().strip()
|
||||
downloads_dir = self.downloads_dir_edit.text().strip()
|
||||
|
||||
# Get authentication token (OAuth or API key) with automatic refresh
|
||||
api_key, oauth_info = self.auth_service.get_auth_for_engine()
|
||||
if not api_key:
|
||||
self._abort_with_message(
|
||||
"warning",
|
||||
"Authorisation Required",
|
||||
"Please authorise with Nexus Mods before installing modlists.\n\n"
|
||||
"Click the 'Authorise' button above to log in with OAuth,\n"
|
||||
"or configure an API key in Settings.",
|
||||
safety_level="medium"
|
||||
)
|
||||
return
|
||||
|
||||
# Log authentication status at install start (Issue #111 diagnostics)
|
||||
auth_method = self.auth_service.get_auth_method()
|
||||
logger.info("=" * 60)
|
||||
logger.info("Authentication Status at Install Start")
|
||||
logger.info(f"Method: {auth_method or 'UNKNOWN'}")
|
||||
logger.info(f"Token length: {len(api_key)} chars")
|
||||
if len(api_key) >= 8:
|
||||
logger.info(f"Token (partial): {api_key[:4]}...{api_key[-4:]}")
|
||||
|
||||
if auth_method == 'oauth':
|
||||
token_handler = self.auth_service.token_handler
|
||||
token_info = token_handler.get_token_info()
|
||||
if 'expires_in_minutes' in token_info:
|
||||
logger.info(f"OAuth expires in: {token_info['expires_in_minutes']:.1f} minutes")
|
||||
if token_info.get('refresh_token_likely_expired'):
|
||||
logger.warning(f"OAuth refresh token age: {token_info['refresh_token_age_days']:.1f} days (may need re-auth)")
|
||||
logger.info("=" * 60)
|
||||
|
||||
modlist_name = self.modlist_name_edit.text().strip()
|
||||
missing_fields = []
|
||||
if not modlist_name:
|
||||
missing_fields.append("Modlist Name")
|
||||
if not install_dir:
|
||||
missing_fields.append("Install Directory")
|
||||
if not downloads_dir:
|
||||
missing_fields.append("Downloads Directory")
|
||||
if missing_fields:
|
||||
self._abort_with_message(
|
||||
"warning",
|
||||
"Missing Required Fields",
|
||||
"Please fill in all required fields before starting the install:\n- " + "\n- ".join(missing_fields)
|
||||
)
|
||||
return
|
||||
from jackify.backend.handlers.validation_handler import ValidationHandler
|
||||
validation_handler = ValidationHandler()
|
||||
is_safe, reason = validation_handler.is_safe_install_directory(Path(install_dir))
|
||||
if not is_safe:
|
||||
from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog
|
||||
dlg = WarningDialog(reason, parent=self)
|
||||
result = dlg.exec()
|
||||
if not result or not dlg.confirmed:
|
||||
self._abort_install_validation()
|
||||
return
|
||||
if not os.path.isdir(install_dir):
|
||||
from ..services.message_service import MessageService
|
||||
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.show_error(self, install_dir_create_failed(install_dir, str(e)))
|
||||
self._abort_install_validation()
|
||||
return
|
||||
else:
|
||||
self._abort_install_validation()
|
||||
return
|
||||
if not os.path.isdir(downloads_dir):
|
||||
from ..services.message_service import MessageService
|
||||
create = MessageService.question(self, "Create Directory?",
|
||||
f"The downloads directory does not exist:\n{downloads_dir}\n\nWould you like to create it?",
|
||||
critical=False # Non-critical, won't steal focus
|
||||
)
|
||||
if create == QMessageBox.Yes:
|
||||
try:
|
||||
os.makedirs(downloads_dir, exist_ok=True)
|
||||
except Exception as e:
|
||||
MessageService.show_error(self, install_dir_create_failed(downloads_dir, str(e)))
|
||||
self._abort_install_validation()
|
||||
return
|
||||
else:
|
||||
self._abort_install_validation()
|
||||
return
|
||||
|
||||
# Handle resolution saving
|
||||
resolution = self.resolution_combo.currentText()
|
||||
if resolution and resolution != "Leave unchanged":
|
||||
success = self.resolution_service.save_resolution(resolution)
|
||||
if success:
|
||||
logger.debug(f"DEBUG: Resolution saved successfully: {resolution}")
|
||||
else:
|
||||
logger.debug("DEBUG: Failed to save resolution")
|
||||
else:
|
||||
# Clear saved resolution if "Leave unchanged" is selected
|
||||
if self.resolution_service.has_saved_resolution():
|
||||
self.resolution_service.clear_saved_resolution()
|
||||
logger.debug("DEBUG: Saved resolution cleared")
|
||||
|
||||
ensure_flatpak_steam_filesystem_access(Path(install_dir))
|
||||
|
||||
# Handle parent directory saving
|
||||
self._save_parent_directories(install_dir, downloads_dir)
|
||||
|
||||
# Detect game type and check support
|
||||
game_type = None
|
||||
game_name = None
|
||||
|
||||
if install_mode == 'file':
|
||||
# Parse .wabbajack file to get game type
|
||||
wabbajack_path = Path(modlist)
|
||||
result = self.wabbajack_parser.parse_wabbajack_game_type(wabbajack_path)
|
||||
if result:
|
||||
if isinstance(result, tuple):
|
||||
game_type, raw_game_type = result
|
||||
# Get display name for the game
|
||||
display_names = {
|
||||
'skyrim': 'Skyrim',
|
||||
'fallout4': 'Fallout 4',
|
||||
'falloutnv': 'Fallout New Vegas',
|
||||
'oblivion': 'Oblivion',
|
||||
'starfield': 'Starfield',
|
||||
'oblivion_remastered': 'Oblivion Remastered',
|
||||
'enderal': 'Enderal'
|
||||
}
|
||||
if game_type == 'unknown' and raw_game_type:
|
||||
game_name = raw_game_type
|
||||
else:
|
||||
game_name = display_names.get(game_type, game_type)
|
||||
else:
|
||||
game_type = result
|
||||
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(game_type, game_type)
|
||||
else:
|
||||
# For online modlists, try to get game type from selected modlist
|
||||
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
|
||||
game_name = self.selected_modlist_info.get('game', '')
|
||||
logger.debug(f"DEBUG: Detected game_name from selected_modlist_info: '{game_name}'")
|
||||
|
||||
# Map game name to game type
|
||||
game_mapping = {
|
||||
'skyrim special edition': 'skyrim',
|
||||
'skyrim': 'skyrim',
|
||||
'fallout 4': 'fallout4',
|
||||
'fallout new vegas': 'falloutnv',
|
||||
'oblivion': 'oblivion',
|
||||
'starfield': 'starfield',
|
||||
'oblivion_remastered': 'oblivion_remastered',
|
||||
'enderal': 'enderal',
|
||||
'enderal special edition': 'enderal'
|
||||
}
|
||||
game_type = game_mapping.get(game_name.lower())
|
||||
logger.debug(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'")
|
||||
if not game_type:
|
||||
game_type = 'unknown'
|
||||
logger.debug(f"DEBUG: Game type not found in mapping, setting to 'unknown'")
|
||||
else:
|
||||
logger.debug(f"DEBUG: No selected_modlist_info found")
|
||||
game_type = 'unknown'
|
||||
|
||||
# Store game type and name for later use
|
||||
self._current_game_type = game_type
|
||||
self._current_game_name = game_name
|
||||
|
||||
# Check if game is supported
|
||||
logger.debug(f"DEBUG: Checking if game_type '{game_type}' is supported")
|
||||
logger.debug(f"DEBUG: game_type='{game_type}', game_name='{game_name}'")
|
||||
is_supported = self.wabbajack_parser.is_supported_game(game_type) if game_type else False
|
||||
logger.debug(f"DEBUG: is_supported_game('{game_type}') returned: {is_supported}")
|
||||
|
||||
if game_type and not is_supported:
|
||||
logger.debug(f"DEBUG: Game '{game_type}' is not supported, showing dialog")
|
||||
# Show unsupported game dialog
|
||||
from ..widgets.unsupported_game_dialog import UnsupportedGameDialog
|
||||
dialog = UnsupportedGameDialog(self, game_name)
|
||||
if not dialog.show_dialog(self, game_name):
|
||||
self._abort_install_validation()
|
||||
return
|
||||
|
||||
self.console.clear()
|
||||
self.process_monitor.clear()
|
||||
|
||||
# Collapse Show Details if it was left open by the previous run.
|
||||
if self.show_details_checkbox.isChecked():
|
||||
self.show_details_checkbox.blockSignals(True)
|
||||
self.show_details_checkbox.setChecked(False)
|
||||
self.show_details_checkbox.blockSignals(False)
|
||||
from PySide6.QtCore import Qt as _Qt
|
||||
self._toggle_console_visibility(_Qt.Unchecked)
|
||||
|
||||
# R&D: Reset progress indicator for new installation
|
||||
self.progress_indicator.reset()
|
||||
self.progress_state_manager.reset()
|
||||
self.file_progress_list.clear()
|
||||
self.file_progress_list.start_cpu_tracking() # Start tracking CPU during installation
|
||||
self._is_update_install = False
|
||||
self._existing_shortcut_appid = None
|
||||
self._premium_notice_shown = False
|
||||
self._stalled_download_start_time = None
|
||||
self._stalled_download_notified = False
|
||||
self._stalled_data_snapshot = 0
|
||||
self._token_error_notified = False # Reset token error notification
|
||||
self._premium_failure_active = False
|
||||
self._installation_cancelled = False
|
||||
self._non_premium_gate_enabled = False
|
||||
self._non_premium_info_acknowledged = False
|
||||
self._pending_manual_download_events = None
|
||||
self._post_install_active = False
|
||||
self._post_install_current_step = 0
|
||||
# Activity tab is always visible (tabs handle visibility automatically)
|
||||
|
||||
# Update button states for installation
|
||||
self.start_btn.setEnabled(False)
|
||||
self.cancel_btn.setVisible(False)
|
||||
self.cancel_install_btn.setVisible(True)
|
||||
|
||||
# Detect update-vs-new workflow before starting engine install.
|
||||
from jackify.backend.utils.modlist_meta import JACKIFY_META_FILE
|
||||
install_real = os.path.realpath(install_dir)
|
||||
meta_exists = (Path(install_real) / JACKIFY_META_FILE).exists()
|
||||
existing_appid = self._find_existing_shortcut_appid(modlist_name, install_real)
|
||||
if meta_exists and existing_appid:
|
||||
eligible, update_meta = self._evaluate_update_candidate(
|
||||
modlist_name,
|
||||
install_real,
|
||||
install_mode,
|
||||
existing_appid,
|
||||
)
|
||||
if not eligible:
|
||||
logger.info(
|
||||
"Update mode not offered | reason=%s requested_name=%s installed_name=%s",
|
||||
update_meta.get("reason"),
|
||||
modlist_name,
|
||||
update_meta.get("installed_name"),
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Update mode candidate | version_relation=%s requested_version=%s installed_version=%s",
|
||||
update_meta.get("version_relation"),
|
||||
update_meta.get("requested_version"),
|
||||
update_meta.get("installed_version"),
|
||||
)
|
||||
decision = self._prompt_update_or_new_install(modlist_name, install_real, update_meta)
|
||||
if decision == "cancel":
|
||||
self._abort_install_validation()
|
||||
return
|
||||
if decision == "new":
|
||||
from ..services.message_service import MessageService
|
||||
|
||||
MessageService.warning(
|
||||
self,
|
||||
"Shortcut Name Already Exists",
|
||||
"A Steam shortcut with this name already points to this install directory.\n\n"
|
||||
"For a new install, choose a different Modlist Name before starting.",
|
||||
safety_level="medium",
|
||||
)
|
||||
self._abort_install_validation()
|
||||
return
|
||||
# update
|
||||
self._is_update_install = True
|
||||
self._existing_shortcut_appid = existing_appid
|
||||
self._safe_append_text(
|
||||
f"Update mode selected. Reusing existing Steam shortcut AppID {existing_appid}."
|
||||
)
|
||||
self._record_pre_update_ini_snapshot(install_real)
|
||||
|
||||
# CRITICAL: Final safety check - ensure online modlists use machine_url
|
||||
if install_mode == 'online':
|
||||
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
|
||||
expected_machine_url = self.selected_modlist_info.get('machine_url')
|
||||
if expected_machine_url:
|
||||
modlist = expected_machine_url # Force use machine_url
|
||||
else:
|
||||
self._abort_with_message(
|
||||
"critical",
|
||||
"Installation Error",
|
||||
"Cannot determine modlist machine URL. Please select the modlist again."
|
||||
)
|
||||
return
|
||||
else:
|
||||
self._abort_with_message(
|
||||
"critical",
|
||||
"Installation Error",
|
||||
"Modlist information is missing. Please select the modlist again from the gallery."
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug(f'DEBUG: Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, install_mode={install_mode}')
|
||||
self.run_modlist_installer(modlist, install_dir, downloads_dir, api_key, install_mode, oauth_info)
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Exception in validate_and_start_install: {e}")
|
||||
import traceback
|
||||
logger.debug(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)
|
||||
logger.debug(f"DEBUG: Controls re-enabled in exception handler")
|
||||
|
||||
def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online', oauth_info=None):
|
||||
logger.debug('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER')
|
||||
|
||||
# Rotate log file at start of each workflow run (keep 5 backups)
|
||||
from jackify.backend.handlers.logging_handler import LoggingHandler
|
||||
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()
|
||||
from jackify import __version__ as jackify_version
|
||||
self._safe_append_text(f"Jackify v{jackify_version}")
|
||||
self._safe_append_text("Starting modlist installation with custom progress handling...")
|
||||
|
||||
# Update UI state for installation
|
||||
self.start_btn.setEnabled(False)
|
||||
self.cancel_btn.setVisible(False)
|
||||
self.cancel_install_btn.setVisible(True)
|
||||
|
||||
self._downloads_dir = downloads_dir
|
||||
self.install_thread = InstallerThread(
|
||||
modlist, install_dir, downloads_dir, api_key, self.modlist_name_edit.text().strip(), install_mode,
|
||||
progress_state_manager=self.progress_state_manager, # R&D: Pass progress state manager
|
||||
auth_service=self.auth_service, # Fix Issue #127: Pass auth_service for Premium detection diagnostics
|
||||
oauth_info=oauth_info # Pass OAuth state for auto-refresh
|
||||
)
|
||||
self.install_thread.output_received.connect(self.on_installation_output)
|
||||
self.install_thread.progress_received.connect(self.on_installation_progress)
|
||||
self.install_thread.progress_updated.connect(self.on_progress_updated) # R&D: Connect progress update
|
||||
self.install_thread.installation_finished.connect(self.on_installation_finished)
|
||||
self.install_thread.premium_required_detected.connect(self.on_premium_required_detected)
|
||||
self.install_thread.non_premium_detected.connect(self.on_non_premium_detected)
|
||||
self.install_thread.manual_download_list_received.connect(self.on_manual_download_list_received)
|
||||
# R&D: Pass progress state manager to thread
|
||||
self.install_thread.progress_state_manager = self.progress_state_manager
|
||||
self.install_thread.finished.connect(self.install_thread.deleteLater)
|
||||
self.install_thread.start()
|
||||
|
||||
def on_manual_download_list_received(self, events: list) -> None:
|
||||
"""Show the manual download dialog when the engine emits a batch of missing files."""
|
||||
try:
|
||||
# Show non-premium info dialog synchronously before the file list.
|
||||
# The engine is paused waiting for a continue signal at this point,
|
||||
# so process_finished will not fire during exec() and close it prematurely.
|
||||
if getattr(self, '_non_premium_gate_enabled', False) and not getattr(self, '_non_premium_info_acknowledged', False):
|
||||
self._show_non_premium_info_dialog()
|
||||
logger.info(f"[MDL-1005] Showing manual download dialog for batch | items={len(events)}")
|
||||
self._show_manual_download_dialog(events)
|
||||
except Exception as exc:
|
||||
logger.error(f"Manual download dialog setup failed: {exc}", exc_info=True)
|
||||
self._safe_append_text(f"\n[ERROR] Manual download dialog failed to open: {exc}\n")
|
||||
|
||||
def _flush_pending_manual_download_events(self) -> None:
|
||||
events = getattr(self, '_pending_manual_download_events', None)
|
||||
if not events:
|
||||
return
|
||||
self._pending_manual_download_events = None
|
||||
logger.info(f"[MDL-1007] Releasing queued manual download batch after acknowledgement | items={len(events)}")
|
||||
self._show_manual_download_dialog(events)
|
||||
|
||||
def _show_manual_download_dialog(self, events: list) -> None:
|
||||
from pathlib import Path as _Path
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.backend.services.manual_download_manager import ManualDownloadManager
|
||||
from jackify.frontends.gui.dialogs.manual_download_dialog import ManualDownloadDialog
|
||||
|
||||
cfg_watch = ConfigHandler().get("manual_download_watch_directory", None)
|
||||
watch_dir = None
|
||||
if cfg_watch:
|
||||
cfg_path = _Path(str(cfg_watch)).expanduser()
|
||||
if cfg_path.is_dir():
|
||||
watch_dir = cfg_path
|
||||
if watch_dir is None:
|
||||
xdg_dl = Path(os.environ.get('XDG_DOWNLOAD_DIR', '')) if os.environ.get('XDG_DOWNLOAD_DIR') else None
|
||||
watch_dir = xdg_dl if (xdg_dl and xdg_dl.is_dir()) else _Path.home() / 'Downloads'
|
||||
dl_dir = _Path(self._downloads_dir) if hasattr(self, '_downloads_dir') else watch_dir
|
||||
|
||||
loop_iteration = events[0].get('loop_iteration', 1) if events else 1
|
||||
count = len(events)
|
||||
raw_limit = ConfigHandler().get('manual_download_concurrent_limit', 2)
|
||||
try:
|
||||
concurrent_limit = int(raw_limit)
|
||||
except (TypeError, ValueError):
|
||||
concurrent_limit = 2
|
||||
concurrent_limit = max(1, min(5, concurrent_limit))
|
||||
|
||||
self._safe_append_text(
|
||||
f"\n[Manual Download Required] {count} file(s) need manual download.\n"
|
||||
f"Opening download dialog — check your taskbar if it does not appear in front.\n"
|
||||
)
|
||||
logger.info(
|
||||
f"[MDL-1006] Manual download protocol initialized | count={count} "
|
||||
f"loop_iteration={loop_iteration} watch_dir={watch_dir} downloads_dir={dl_dir}"
|
||||
)
|
||||
|
||||
# New install run: start with a fresh manager/dialog to avoid stale statuses from prior runs.
|
||||
if loop_iteration == 1:
|
||||
if getattr(self, '_manual_dl_manager', None) is not None:
|
||||
try:
|
||||
self._manual_dl_manager.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._manual_dl_manager = None
|
||||
if getattr(self, '_manual_dl_dialog', None) is not None:
|
||||
try:
|
||||
self._manual_dl_dialog.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._manual_dl_dialog = None
|
||||
|
||||
if not hasattr(self, '_manual_dl_manager') or self._manual_dl_manager is None:
|
||||
self._manual_dl_manager = ManualDownloadManager(
|
||||
modlist_download_dir=dl_dir,
|
||||
watch_directory=watch_dir,
|
||||
concurrent_limit=concurrent_limit,
|
||||
on_send_continue=self.install_thread.send_continue,
|
||||
)
|
||||
self._manual_dl_dialog = ManualDownloadDialog(
|
||||
manager=self._manual_dl_manager,
|
||||
modlist_name=self.modlist_name_edit.text().strip() if hasattr(self, 'modlist_name_edit') else '',
|
||||
watch_directory=watch_dir,
|
||||
concurrent_limit=concurrent_limit,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self._manual_dl_manager.load_items(events, loop_iteration)
|
||||
self._manual_dl_dialog.load_items(self._manual_dl_manager.items)
|
||||
|
||||
if not self._manual_dl_dialog.isVisible():
|
||||
self._manual_dl_dialog.show()
|
||||
@@ -304,25 +304,28 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
|
||||
"""Clean up any running processes when the window closes or is cancelled"""
|
||||
logger.debug("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():
|
||||
# install_thread uses cancel() for cooperative shutdown before terminate.
|
||||
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
|
||||
logger.debug("DEBUG: Cancelling running InstallationThread")
|
||||
self.install_thread.cancel()
|
||||
self.install_thread.wait(3000) # Wait up to 3 seconds
|
||||
self.install_thread.wait(3000)
|
||||
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():
|
||||
logger.debug(f"DEBUG: Terminating {thread_name}")
|
||||
thread.terminate()
|
||||
thread.wait(1000) # Wait up to 1 second
|
||||
self.install_thread.wait(2000)
|
||||
self.install_thread = None
|
||||
|
||||
from PySide6.QtCore import QThread
|
||||
for attr_name, value in list(vars(self).items()):
|
||||
if attr_name == 'install_thread':
|
||||
continue
|
||||
try:
|
||||
if isinstance(value, QThread) and value.isRunning():
|
||||
logger.debug(f"DEBUG: Terminating {attr_name}")
|
||||
value.terminate()
|
||||
value.wait(2000)
|
||||
setattr(self, attr_name, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def cancel_installation(self):
|
||||
"""Cancel the currently running installation"""
|
||||
@@ -353,7 +356,7 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
|
||||
""")
|
||||
|
||||
# Cancel the installation thread if it exists
|
||||
if hasattr(self, 'install_thread') and self.install_thread.isRunning():
|
||||
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():
|
||||
@@ -361,7 +364,7 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
|
||||
self.install_thread.wait(1000)
|
||||
|
||||
# Cancel the automated prefix thread if it exists
|
||||
if hasattr(self, 'prefix_thread') and self.prefix_thread.isRunning():
|
||||
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():
|
||||
@@ -369,7 +372,7 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
|
||||
self.prefix_thread.wait(1000)
|
||||
|
||||
# Cancel the configuration thread if it exists
|
||||
if hasattr(self, 'config_thread') and self.config_thread.isRunning():
|
||||
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():
|
||||
|
||||
@@ -51,6 +51,9 @@ class TTWOutputMixin:
|
||||
is_file_op = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx'])
|
||||
is_noise = cleaned.strip().upper() in ['OK', 'OK.', 'OK!', 'DONE', 'DONE.', 'SUCCESS', 'SUCCESS.']
|
||||
|
||||
if is_error and 'cannot get directory path for location type' in lower_cleaned:
|
||||
self._ttw_unclean_game_dir_detected = True
|
||||
|
||||
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:
|
||||
|
||||
@@ -143,7 +143,11 @@ class TTWInstallationThread(QThread):
|
||||
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}")
|
||||
self.installation_finished.emit(
|
||||
False,
|
||||
f"TTW installer exited unexpectedly (code {returncode}). "
|
||||
"Review the recent console output for the failing step."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -209,6 +209,14 @@ class TTWWorkflowMixin:
|
||||
font-size: 13px;
|
||||
""")
|
||||
self._safe_append_text(f"\nError: {message}")
|
||||
if getattr(self, '_ttw_unclean_game_dir_detected', False):
|
||||
self._safe_append_text(
|
||||
"\nLikely cause: Your Fallout New Vegas game directory is not clean vanilla.\n"
|
||||
"TTW requires an unmodified FNV installation to patch correctly.\n"
|
||||
"If you have previously installed an FNV modlist that modifies the game directory,\n"
|
||||
"verify or reinstall FNV via Steam to restore vanilla files, then try again."
|
||||
)
|
||||
self._last_install_message = message
|
||||
self.process_finished(1, QProcess.CrashExit)
|
||||
|
||||
def process_finished(self, exit_code, exit_status):
|
||||
@@ -247,9 +255,11 @@ class TTWWorkflowMixin:
|
||||
)
|
||||
else:
|
||||
last_output = self.console.toPlainText()
|
||||
if "cancelled by user" in last_output.lower():
|
||||
failure_msg = (getattr(self, '_last_install_message', '') or "").strip()
|
||||
if "cancelled by user" in last_output.lower() or "cancelled by user" in failure_msg.lower():
|
||||
MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low")
|
||||
else:
|
||||
MessageService.show_error(self, wabbajack_install_failed(f"Exit code {exit_code}. Check the console output for details."))
|
||||
self._safe_append_text(f"\nInstall failed (exit code {exit_code}).")
|
||||
user_summary = failure_msg or "TTW installation failed. Review recent console output for the failing step."
|
||||
MessageService.show_error(self, wabbajack_install_failed(user_summary))
|
||||
self._safe_append_text(f"\nInstall failed: {user_summary}")
|
||||
self.console.moveCursor(QTextCursor.End)
|
||||
|
||||
@@ -6,6 +6,7 @@ should use this mixin so the main window consistently collapses when leaving.
|
||||
"""
|
||||
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtWidgets import QSizePolicy
|
||||
from ..utils import set_responsive_minimum
|
||||
|
||||
|
||||
@@ -48,3 +49,48 @@ class ScreenBackMixin:
|
||||
self.show_details_checkbox.blockSignals(False)
|
||||
if not is_steamdeck and hasattr(self, "_toggle_console_visibility"):
|
||||
self._toggle_console_visibility(Qt.Unchecked)
|
||||
|
||||
def force_collapsed_details_state(self, resize_mode: str = "compact"):
|
||||
"""
|
||||
Normalize Show Details state when a screen is opened/reset.
|
||||
|
||||
Some screens still manage console visibility locally instead of through a
|
||||
single shared widget module. This helper forces the collapsed state in a
|
||||
way that is safe across those implementations.
|
||||
"""
|
||||
try:
|
||||
if hasattr(self, "show_details_checkbox"):
|
||||
self.show_details_checkbox.blockSignals(True)
|
||||
self.show_details_checkbox.setChecked(False)
|
||||
self.show_details_checkbox.blockSignals(False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if hasattr(self, "console"):
|
||||
self.console.setVisible(False)
|
||||
self.console.setMinimumHeight(0)
|
||||
self.console.setMaximumHeight(0)
|
||||
self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if hasattr(self, "console_and_buttons_widget"):
|
||||
self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.console_and_buttons_widget.setFixedHeight(50)
|
||||
self.console_and_buttons_widget.updateGeometry()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if hasattr(self, "main_overall_vbox") and hasattr(self, "console"):
|
||||
self.main_overall_vbox.setStretchFactor(self.console, 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if hasattr(self, "resize_request"):
|
||||
self.resize_request.emit(resize_mode)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
58
jackify/frontends/gui/screens/screen_focus_reclaim.py
Normal file
58
jackify/frontends/gui/screens/screen_focus_reclaim.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Shared mixin for reclaiming window focus after Steam restart."""
|
||||
import logging
|
||||
from PySide6.QtCore import QTimer, Qt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STEAM_RESTART_SENTINEL = "[Jackify] Steam restart complete"
|
||||
|
||||
|
||||
class FocusReclaimMixin:
|
||||
"""Mixin providing post-Steam-restart focus reclaim for any screen.
|
||||
|
||||
Usage: inherit this mixin and call _start_focus_reclaim_retries() when
|
||||
Steam restart is detected. Detection is typically done by checking
|
||||
progress messages for STEAM_RESTART_SENTINEL.
|
||||
"""
|
||||
|
||||
def _start_focus_reclaim_retries(self):
|
||||
try:
|
||||
if hasattr(self, "_focus_reclaim_timer") and self._focus_reclaim_timer:
|
||||
self._focus_reclaim_timer.stop()
|
||||
self._focus_reclaim_timer.deleteLater()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._focus_reclaim_attempt = 0
|
||||
self._focus_reclaim_max_attempts = 12 # ~24 seconds total
|
||||
self._focus_reclaim_timer = QTimer(self)
|
||||
self._focus_reclaim_timer.setInterval(2000)
|
||||
self._focus_reclaim_timer.timeout.connect(self._focus_reclaim_tick)
|
||||
self._focus_reclaim_timer.start()
|
||||
self._focus_reclaim_tick()
|
||||
|
||||
def _focus_reclaim_tick(self):
|
||||
try:
|
||||
win = self.window()
|
||||
if win is None:
|
||||
return
|
||||
|
||||
self._focus_reclaim_attempt += 1
|
||||
win.raise_()
|
||||
win.activateWindow()
|
||||
win.setWindowState(win.windowState() & ~Qt.WindowMinimized | Qt.WindowActive)
|
||||
|
||||
if win.isActiveWindow():
|
||||
logger.info("Foreground focus reclaimed after Steam restart")
|
||||
self._focus_reclaim_timer.stop()
|
||||
return
|
||||
|
||||
if self._focus_reclaim_attempt >= self._focus_reclaim_max_attempts:
|
||||
logger.warning("Foreground focus reclaim timed out after Steam restart")
|
||||
self._focus_reclaim_timer.stop()
|
||||
except Exception as e:
|
||||
logger.debug(f"Focus reclaim tick failed: {e}")
|
||||
try:
|
||||
self._focus_reclaim_timer.stop()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -20,9 +20,12 @@ from PySide6.QtCore import Qt, QThread, Signal, QSize
|
||||
from PySide6.QtGui import QTextCursor
|
||||
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
from jackify.shared.errors import wabbajack_install_failed
|
||||
from ..dialogs.existing_setup_dialog import prompt_existing_setup_dialog
|
||||
from ..services.message_service import MessageService
|
||||
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
|
||||
from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
|
||||
from ..utils import set_responsive_minimum
|
||||
from ..widgets.file_progress_list import FileProgressList
|
||||
from ..widgets.progress_indicator import OverallProgressIndicator
|
||||
@@ -39,11 +42,12 @@ class WabbajackInstallerWorker(QThread):
|
||||
log_output = Signal(str) # Console log output
|
||||
installation_complete = Signal(bool, str, str, str, str) # Success, message, launch_options, app_id, time_taken
|
||||
|
||||
def __init__(self, install_folder: Path, shortcut_name: str = "Wabbajack", enable_gog: bool = True):
|
||||
def __init__(self, install_folder: Path, shortcut_name: str = "Wabbajack", enable_gog: bool = True, existing_appid: int | None = None):
|
||||
super().__init__()
|
||||
self.install_folder = install_folder
|
||||
self.shortcut_name = shortcut_name
|
||||
self.enable_gog = enable_gog
|
||||
self.existing_appid = existing_appid
|
||||
self.launch_options = "" # Store launch options for success message
|
||||
self.start_time = None # Track installation start time
|
||||
|
||||
@@ -73,6 +77,7 @@ class WabbajackInstallerWorker(QThread):
|
||||
install_folder=self.install_folder,
|
||||
shortcut_name=self.shortcut_name,
|
||||
enable_gog=self.enable_gog,
|
||||
existing_appid=self.existing_appid,
|
||||
progress_callback=progress_callback,
|
||||
log_callback=log_callback
|
||||
)
|
||||
@@ -84,7 +89,7 @@ class WabbajackInstallerWorker(QThread):
|
||||
self.installation_complete.emit(False, error_msg or "Installation failed", "", "", "")
|
||||
|
||||
|
||||
class WabbajackInstallerScreen(ScreenBackMixin, QWidget):
|
||||
class WabbajackInstallerScreen(ScreenBackMixin, FocusReclaimMixin, QWidget):
|
||||
"""Wabbajack installer GUI screen following standard Jackify layout"""
|
||||
|
||||
resize_request = Signal(str)
|
||||
@@ -347,10 +352,17 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget):
|
||||
|
||||
def _on_show_details_toggled(self, checked):
|
||||
"""Handle Show details checkbox toggle"""
|
||||
self.console.setVisible(checked)
|
||||
if checked:
|
||||
self.console.setVisible(True)
|
||||
self.console.setMinimumHeight(200)
|
||||
self.console.setMaximumHeight(16777215)
|
||||
self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.resize_request.emit("expand")
|
||||
else:
|
||||
self.console.setVisible(False)
|
||||
self.console.setMinimumHeight(0)
|
||||
self.console.setMaximumHeight(0)
|
||||
self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
|
||||
self.resize_request.emit("compact")
|
||||
|
||||
def _browse_folder(self):
|
||||
@@ -398,6 +410,51 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget):
|
||||
if confirm != QMessageBox.Yes:
|
||||
return
|
||||
|
||||
existing_appid = None
|
||||
candidate_exe = self.install_folder / "Wabbajack.exe"
|
||||
prefix_service = AutomatedPrefixService()
|
||||
conflict_result = prefix_service.handle_existing_shortcut_conflict(
|
||||
self.shortcut_name,
|
||||
str(candidate_exe),
|
||||
str(self.install_folder),
|
||||
)
|
||||
if isinstance(conflict_result, list):
|
||||
action, new_name = prompt_existing_setup_dialog(
|
||||
self,
|
||||
window_title="Existing Modlist Setup Detected",
|
||||
heading="Use Existing Setup or Create a New Shortcut",
|
||||
body=(
|
||||
"Jackify found an existing Steam shortcut for this Wabbajack setup.\n\n"
|
||||
"Choose 'Use Existing Setup' to reuse the current Steam shortcut, or enter a "
|
||||
"different name to create a separate shortcut."
|
||||
),
|
||||
existing_name=conflict_result[0].get("name", self.shortcut_name),
|
||||
requested_name=self.shortcut_name,
|
||||
install_dir=str(self.install_folder),
|
||||
field_label="New shortcut name",
|
||||
reuse_label="Use Existing Setup",
|
||||
new_label="Create New Shortcut",
|
||||
cancel_label="Cancel",
|
||||
)
|
||||
if action == "reuse":
|
||||
existing_appid = conflict_result[0].get("appid")
|
||||
if not existing_appid:
|
||||
MessageService.warning(self, "Existing Setup Not Found", "Jackify could not determine the Steam AppID for the existing shortcut.")
|
||||
return
|
||||
self._write_to_log_file(f"Reusing existing Steam shortcut '{self.shortcut_name}' with AppID {existing_appid}")
|
||||
elif action == "new":
|
||||
if not new_name:
|
||||
MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.")
|
||||
return
|
||||
if new_name == self.shortcut_name:
|
||||
MessageService.warning(self, "Same Name", "Please enter a different name to create a separate shortcut.")
|
||||
return
|
||||
self.shortcut_name = new_name
|
||||
self.shortcut_name_edit.setText(new_name)
|
||||
else:
|
||||
self._write_to_log_file("Shortcut creation cancelled by user")
|
||||
return
|
||||
|
||||
# Clear displays
|
||||
self.console.clear()
|
||||
self.file_progress_list.clear()
|
||||
@@ -420,7 +477,7 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget):
|
||||
self.progress_indicator.set_status("Starting installation...", 0)
|
||||
|
||||
# Start worker thread
|
||||
self.worker = WabbajackInstallerWorker(self.install_folder, shortcut_name=self.shortcut_name, enable_gog=True)
|
||||
self.worker = WabbajackInstallerWorker(self.install_folder, shortcut_name=self.shortcut_name, enable_gog=True, existing_appid=int(existing_appid) if existing_appid else None)
|
||||
self.worker.progress_update.connect(self._on_progress_update)
|
||||
self.worker.activity_update.connect(self._on_activity_update)
|
||||
self.worker.log_output.connect(self._on_log_output)
|
||||
@@ -428,8 +485,9 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget):
|
||||
self.worker.start()
|
||||
|
||||
def _on_progress_update(self, message: str, percentage: int):
|
||||
"""Handle progress updates"""
|
||||
self.progress_indicator.set_status(message, percentage)
|
||||
if STEAM_RESTART_SENTINEL in message:
|
||||
self._start_focus_reclaim_retries()
|
||||
|
||||
def _on_activity_update(self, label: str, current: int, total: int):
|
||||
"""Handle activity tab updates"""
|
||||
@@ -569,6 +627,7 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget):
|
||||
def showEvent(self, event):
|
||||
"""Called when widget becomes visible"""
|
||||
super().showEvent(event)
|
||||
self.force_collapsed_details_state()
|
||||
try:
|
||||
main_window = self.window()
|
||||
if main_window:
|
||||
|
||||
402
jackify/frontends/gui/services/vnv_automation_controller.py
Normal file
402
jackify/frontends/gui/services/vnv_automation_controller.py
Normal file
@@ -0,0 +1,402 @@
|
||||
"""
|
||||
Shared VNV post-install automation controller for all GUI workflows.
|
||||
|
||||
Handles VNV detection, user confirmation, premium/non-premium download paths,
|
||||
worker thread management, and completion callbacks.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from PySide6.QtCore import QThread, Signal, Slot, QObject
|
||||
from PySide6.QtWidgets import QMessageBox, QWidget
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _VNVWorker(QThread):
|
||||
"""Background thread for VNV automation."""
|
||||
progress_update = Signal(str)
|
||||
completed = Signal(bool, str) # (success, error_message)
|
||||
|
||||
def __init__(self, modlist_name, install_path, game_root, ttw_installer_path):
|
||||
super().__init__()
|
||||
self._modlist_name = modlist_name
|
||||
self._install_path = install_path
|
||||
self._game_root = game_root
|
||||
self._ttw_installer_path = ttw_installer_path
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable
|
||||
automation_ran, error = run_vnv_automation_if_applicable(
|
||||
modlist_name=self._modlist_name,
|
||||
modlist_install_location=self._install_path,
|
||||
game_root=self._game_root,
|
||||
ttw_installer_path=self._ttw_installer_path,
|
||||
progress_callback=self.progress_update.emit,
|
||||
manual_file_callback=None,
|
||||
confirmation_callback=lambda desc: True,
|
||||
)
|
||||
self.completed.emit(error is None, error or "")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self.completed.emit(False, f"{e}\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
class VNVAutomationController(QObject):
|
||||
"""
|
||||
Single entry point for VNV post-install automation across all GUI workflows.
|
||||
|
||||
Usage in any screen's on_configuration_complete:
|
||||
|
||||
from ..services.vnv_automation_controller import VNVAutomationController
|
||||
controller = VNVAutomationController()
|
||||
if controller.attempt(
|
||||
parent=self,
|
||||
modlist_name=modlist_name,
|
||||
install_dir=install_dir,
|
||||
on_progress=self._safe_append_text,
|
||||
on_complete=lambda success, error: self._on_vnv_done(success, error),
|
||||
):
|
||||
# VNV is running, defer success dialog
|
||||
return
|
||||
# No VNV, show success dialog now
|
||||
"""
|
||||
|
||||
# Emitted from the watcher background thread; delivered on main thread
|
||||
# via auto-queued connection because this object lives on the main thread.
|
||||
_worker_start_requested = Signal()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._worker: Optional[_VNVWorker] = None
|
||||
self._manual_manager = None
|
||||
self._manual_dialog = None
|
||||
self._pending_worker_start: Optional[Callable] = None
|
||||
self._on_progress_cb: Optional[Callable] = None
|
||||
self._on_complete_cb: Optional[Callable] = None
|
||||
self._handle_feedback_cb: Optional[Callable] = None
|
||||
self._worker_start_requested.connect(self._dispatch_worker_start)
|
||||
|
||||
def attempt(
|
||||
self,
|
||||
parent: QWidget,
|
||||
modlist_name: str,
|
||||
install_dir: str,
|
||||
on_progress: Callable[[str], None],
|
||||
on_complete: Callable[[bool, str], None],
|
||||
begin_feedback: Optional[Callable[[], None]] = None,
|
||||
handle_feedback: Optional[Callable[[str], None]] = None,
|
||||
) -> bool:
|
||||
"""Check for VNV eligibility and start automation if applicable.
|
||||
|
||||
Args:
|
||||
parent: Parent QWidget for dialogs
|
||||
modlist_name: Name of the modlist
|
||||
install_dir: Installation directory path
|
||||
on_progress: Called with progress text messages
|
||||
on_complete: Called with (success, error_message) when done
|
||||
begin_feedback: Optional - start post-install progress UI
|
||||
handle_feedback: Optional - update post-install progress UI
|
||||
|
||||
Returns:
|
||||
True if VNV automation is starting (caller should defer success dialog)
|
||||
False if no VNV needed (caller should show success dialog immediately)
|
||||
"""
|
||||
try:
|
||||
from jackify.backend.services.vnv_integration_helper import should_offer_vnv_automation
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
from jackify.backend.services.vnv_post_install_service import VNVPostInstallService
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
|
||||
install_path = Path(install_dir)
|
||||
|
||||
if not should_offer_vnv_automation(modlist_name, install_path):
|
||||
return False
|
||||
|
||||
game_paths = PathHandler().find_vanilla_game_paths()
|
||||
game_root = game_paths.get('Fallout New Vegas')
|
||||
if not game_root:
|
||||
logger.debug("VNV automation skipped - FNV game root not found")
|
||||
on_progress("VNV automation skipped: Fallout New Vegas path not found")
|
||||
return False
|
||||
|
||||
# Check completion status
|
||||
vnv_service = VNVPostInstallService(
|
||||
modlist_install_location=install_path,
|
||||
game_root=game_root,
|
||||
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
|
||||
)
|
||||
completed = vnv_service.check_already_completed()
|
||||
if completed['root_mods'] and completed['4gb_patch'] and completed['bsa_decompressed']:
|
||||
logger.info("VNV automation steps already completed")
|
||||
return False
|
||||
|
||||
# Confirmation dialog
|
||||
from .message_service import MessageService
|
||||
reply = MessageService.question(
|
||||
parent,
|
||||
"VNV Post-Install Automation",
|
||||
vnv_service.get_automation_description(),
|
||||
critical=False,
|
||||
safety_level="medium",
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
logger.info("User declined VNV automation")
|
||||
on_progress("VNV automation skipped by user")
|
||||
return False
|
||||
|
||||
ttw_installer_path = AutomatedPrefixService.get_ttw_installer_path()
|
||||
|
||||
# Non-premium path: route 4GB patcher through ManualDownloadManager
|
||||
from jackify.backend.services.nexus_auth_service import NexusAuthService
|
||||
from jackify.backend.services.nexus_premium_service import NexusPremiumService
|
||||
|
||||
auth_svc = NexusAuthService()
|
||||
token = auth_svc.get_auth_token()
|
||||
is_premium = False
|
||||
if token:
|
||||
is_premium, _ = NexusPremiumService().check_premium_status(
|
||||
token, is_oauth=(auth_svc.get_auth_method() == "oauth")
|
||||
)
|
||||
|
||||
if not is_premium:
|
||||
has_4gb_cache = vnv_service._find_cached_4gb_patcher() is not None
|
||||
has_bsa_cache = (
|
||||
vnv_service._find_cached_bsa_mpi() is not None or
|
||||
vnv_service._find_cached_bsa_package() is not None
|
||||
)
|
||||
if has_4gb_cache and has_bsa_cache:
|
||||
logger.debug("VNV non-premium: required VNV tools already cached, proceeding to worker")
|
||||
else:
|
||||
tool_events = vnv_service.get_manual_download_items(include_bsa=not has_bsa_cache)
|
||||
logger.debug("VNV non-premium: tool_events=%d, cache_dir=%s", len(tool_events), vnv_service.cache_dir)
|
||||
if tool_events:
|
||||
if begin_feedback:
|
||||
begin_feedback()
|
||||
self._show_tool_download_dialog(
|
||||
parent, tool_events, vnv_service.cache_dir,
|
||||
modlist_name, install_path, game_root, ttw_installer_path,
|
||||
on_progress, on_complete, handle_feedback,
|
||||
)
|
||||
return True
|
||||
else:
|
||||
# Nexus API unavailable — can't auto-track the download.
|
||||
# Open the mod page so the user can get it manually and inform
|
||||
# them where to place it so the worker finds it next time.
|
||||
logger.warning("VNV non-premium: Nexus API query failed, cannot open download manager")
|
||||
try:
|
||||
import subprocess
|
||||
subprocess.Popen(['xdg-open', 'https://www.nexusmods.com/newvegas/mods/62552?tab=files'])
|
||||
except Exception:
|
||||
pass
|
||||
from .message_service import MessageService
|
||||
MessageService.information(
|
||||
parent,
|
||||
"VNV Tools — Manual Download Required",
|
||||
"Jackify could not query the Nexus download URL(s) (check your Nexus login in Settings).\n\n"
|
||||
"Your modlist has been installed successfully.\n\n"
|
||||
"To complete VNV post-install setup, please:\n"
|
||||
"1. Download the '4GB Patcher (Linux/Proton)' from:\n"
|
||||
" nexusmods.com/newvegas/mods/62552\n\n"
|
||||
"2. Download the BSA Decompressor package from:\n"
|
||||
" nexusmods.com/newvegas/mods/65854\n\n"
|
||||
f"3. Place the archive(s) in:\n {vnv_service.cache_dir}\n\n"
|
||||
"4. Re-configure the modlist — Jackify will detect the files automatically.",
|
||||
)
|
||||
return False
|
||||
|
||||
# Premium or all tools already cached - start worker directly
|
||||
if begin_feedback:
|
||||
begin_feedback()
|
||||
self._start_worker(
|
||||
parent, modlist_name, install_path, game_root,
|
||||
ttw_installer_path, on_progress, on_complete, handle_feedback,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to start VNV automation: %s", e)
|
||||
import traceback
|
||||
logger.error("Traceback: %s", traceback.format_exc())
|
||||
return False
|
||||
|
||||
def _dispatch_worker_start(self):
|
||||
"""Slot — always runs on the main thread due to queued signal delivery."""
|
||||
if self._pending_worker_start:
|
||||
fn = self._pending_worker_start
|
||||
self._pending_worker_start = None
|
||||
fn()
|
||||
|
||||
def _show_tool_download_dialog(
|
||||
self, parent, tool_events, cache_dir,
|
||||
modlist_name, install_path, game_root, ttw_installer_path,
|
||||
on_progress, on_complete, handle_feedback,
|
||||
):
|
||||
"""Show ManualDownloadDialog for VNV tools that need manual download."""
|
||||
from jackify.backend.services.manual_download_manager import ManualDownloadManager
|
||||
from jackify.frontends.gui.dialogs.manual_download_dialog import ManualDownloadDialog
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
|
||||
cfg_watch = ConfigHandler().get("manual_download_watch_directory", None)
|
||||
watch_dir = None
|
||||
if cfg_watch:
|
||||
p = Path(str(cfg_watch)).expanduser()
|
||||
if p.is_dir():
|
||||
watch_dir = p
|
||||
if watch_dir is None:
|
||||
import os
|
||||
xdg = os.environ.get('XDG_DOWNLOAD_DIR', '')
|
||||
xdg_path = Path(xdg).expanduser() if xdg else None
|
||||
watch_dir = xdg_path if (xdg_path and xdg_path.is_dir()) else Path.home() / 'Downloads'
|
||||
|
||||
def _on_all_done(_completed, _skipped):
|
||||
# _check_all_done() runs in the watcher background thread (Python
|
||||
# threading.Thread — no Qt event loop). QTimer.singleShot is
|
||||
# unreliable from non-Qt threads. Instead, emit a signal: because
|
||||
# VNVAutomationController was created on the main thread, Qt uses a
|
||||
# queued connection automatically and delivers the slot on the main thread.
|
||||
self._pending_worker_start = lambda: self._finish_manual_download_flow(
|
||||
state,
|
||||
parent,
|
||||
modlist_name,
|
||||
install_path,
|
||||
game_root,
|
||||
ttw_installer_path,
|
||||
on_progress,
|
||||
on_complete,
|
||||
handle_feedback,
|
||||
)
|
||||
self._worker_start_requested.emit()
|
||||
|
||||
state = {"done": False}
|
||||
|
||||
manager = ManualDownloadManager(
|
||||
modlist_download_dir=cache_dir,
|
||||
watch_directory=watch_dir,
|
||||
concurrent_limit=2,
|
||||
on_all_done=_on_all_done,
|
||||
)
|
||||
self._manual_manager = manager
|
||||
manager.load_items(tool_events, loop_iteration=1)
|
||||
|
||||
dialog = ManualDownloadDialog(
|
||||
manager=manager,
|
||||
modlist_name="VNV Post-Install Tools",
|
||||
watch_directory=watch_dir,
|
||||
concurrent_limit=2,
|
||||
parent=parent,
|
||||
)
|
||||
self._manual_dialog = dialog
|
||||
dialog.load_items(manager.items)
|
||||
dialog.finished.connect(lambda _result: self._cancel_manual_download_flow(on_complete, state))
|
||||
dialog.show()
|
||||
|
||||
def _cancel_manual_download_flow(self, on_complete, state: dict) -> None:
|
||||
if state["done"]:
|
||||
return
|
||||
state["done"] = True
|
||||
self._stop_manual_download_flow()
|
||||
on_complete(False, "")
|
||||
|
||||
def _finish_manual_download_flow(
|
||||
self,
|
||||
state: dict,
|
||||
parent,
|
||||
modlist_name,
|
||||
install_path,
|
||||
game_root,
|
||||
ttw_installer_path,
|
||||
on_progress,
|
||||
on_complete,
|
||||
handle_feedback,
|
||||
) -> None:
|
||||
if state["done"]:
|
||||
return
|
||||
state["done"] = True
|
||||
self._stop_manual_download_flow()
|
||||
self._start_worker(
|
||||
parent,
|
||||
modlist_name,
|
||||
install_path,
|
||||
game_root,
|
||||
ttw_installer_path,
|
||||
on_progress,
|
||||
on_complete,
|
||||
handle_feedback,
|
||||
)
|
||||
|
||||
def _stop_manual_download_flow(self) -> None:
|
||||
dialog = self._manual_dialog
|
||||
manager = self._manual_manager
|
||||
self._manual_dialog = None
|
||||
self._manual_manager = None
|
||||
if dialog is not None:
|
||||
try:
|
||||
dialog.finished.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
dialog.close()
|
||||
except Exception:
|
||||
pass
|
||||
if manager is not None:
|
||||
try:
|
||||
manager.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _start_worker(
|
||||
self, parent, modlist_name, install_path, game_root,
|
||||
ttw_installer_path, on_progress, on_complete, handle_feedback,
|
||||
):
|
||||
"""Create and start VNV worker thread.
|
||||
|
||||
Signals are connected to @Slot methods on this QObject (main thread).
|
||||
Because VNVAutomationController lives on the main thread, Qt automatically
|
||||
uses queued connections for signals emitted from the worker thread,
|
||||
guaranteeing that _on_worker_progress and _on_worker_done execute on
|
||||
the main thread regardless of which thread the worker emits from.
|
||||
"""
|
||||
self._on_progress_cb = on_progress
|
||||
self._on_complete_cb = on_complete
|
||||
self._handle_feedback_cb = handle_feedback
|
||||
|
||||
self._worker = _VNVWorker(
|
||||
modlist_name, install_path, game_root, ttw_installer_path,
|
||||
)
|
||||
self._worker.progress_update.connect(self._on_worker_progress)
|
||||
self._worker.completed.connect(self._on_worker_done)
|
||||
self._worker.finished.connect(self._worker.deleteLater)
|
||||
self._worker.start()
|
||||
|
||||
@Slot(str)
|
||||
def _on_worker_progress(self, message: str):
|
||||
if self._on_progress_cb:
|
||||
self._on_progress_cb(message)
|
||||
if self._handle_feedback_cb:
|
||||
self._handle_feedback_cb(message)
|
||||
|
||||
@Slot(bool, str)
|
||||
def _on_worker_done(self, success: bool, error: str):
|
||||
self._worker = None
|
||||
cb = self._on_complete_cb
|
||||
self._on_complete_cb = None
|
||||
self._on_progress_cb = None
|
||||
self._handle_feedback_cb = None
|
||||
if cb:
|
||||
cb(success, error)
|
||||
|
||||
def cleanup(self):
|
||||
"""Stop worker if running. Call from screen cleanup/hideEvent."""
|
||||
self._on_complete_cb = None
|
||||
self._on_progress_cb = None
|
||||
self._handle_feedback_cb = None
|
||||
self._pending_worker_start = None
|
||||
self._stop_manual_download_flow()
|
||||
if self._worker and self._worker.isRunning():
|
||||
self._worker.terminate()
|
||||
self._worker.wait(2000)
|
||||
self._worker = None
|
||||
@@ -342,7 +342,9 @@ class FileProgressList(QWidget):
|
||||
self._cpu_timer.stop()
|
||||
if self._cpu_worker and self._cpu_worker.isRunning():
|
||||
self._cpu_worker.quit()
|
||||
self._cpu_worker.wait(500)
|
||||
if not self._cpu_worker.wait(500):
|
||||
self._cpu_worker.terminate()
|
||||
self._cpu_worker.wait(1000)
|
||||
self._cpu_worker = None
|
||||
|
||||
def _start_cpu_worker(self):
|
||||
|
||||
Reference in New Issue
Block a user