mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 00:17:58 +02:00
658 lines
35 KiB
Python
658 lines
35 KiB
Python
"""Progress and installation event handlers for InstallModlistScreen (Mixin)."""
|
|
from PySide6.QtCore import QProcess
|
|
from PySide6.QtWidgets import QMessageBox
|
|
from PySide6.QtGui import QTextCursor
|
|
from jackify.frontends.gui.services.message_service import MessageService
|
|
from jackify.shared.errors import wabbajack_install_failed
|
|
from jackify.shared.progress_models import InstallationPhase, OperationType, InstallationProgress, FileProgress
|
|
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
|
|
import time
|
|
import logging
|
|
import os
|
|
|
|
logger = logging.getLogger(__name__)
|
|
class ProgressHandlersMixin:
|
|
"""Mixin providing progress tracking and installation event handlers for InstallModlistScreen."""
|
|
|
|
def on_installation_progress(self, progress_message):
|
|
"""
|
|
Handle progress messages from installation thread.
|
|
|
|
NOTE: This is called for MOST engine output, not just progress lines!
|
|
The name is misleading - it's actually the main output path.
|
|
"""
|
|
# Always write output to console buffer (same as on_installation_output)
|
|
self._safe_append_text(progress_message)
|
|
|
|
def on_premium_required_detected(self, engine_line: str):
|
|
"""Handle detection of Nexus Premium requirement."""
|
|
if self._premium_notice_shown:
|
|
return
|
|
|
|
self._premium_notice_shown = True
|
|
self._premium_failure_active = True
|
|
|
|
user_message = (
|
|
"Nexus Mods rejected the automated download because this account is not Premium. "
|
|
"Jackify currently requires a Nexus Premium membership for automated installs, "
|
|
"and non-premium support is still planned."
|
|
)
|
|
|
|
if engine_line:
|
|
logger.warning(f"Nexus Premium required, engine message: {engine_line}")
|
|
self._safe_append_text(f"[Jackify] Engine message: {engine_line}")
|
|
logger.warning("Nexus Premium required for this modlist install")
|
|
self._safe_append_text("[Jackify] Jackify detected that Nexus Premium is required for this modlist install.")
|
|
|
|
MessageService.critical(
|
|
self,
|
|
"Nexus Premium Required",
|
|
f"{user_message}\n\nDetected engine output:\n{engine_line or 'Buy Nexus Premium to automate this process.'}",
|
|
safety_level="medium"
|
|
)
|
|
|
|
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
|
|
# During BSA building, file installation is at 100% but BSAs are still being built
|
|
# Override overall_percent to show BSA building progress instead
|
|
if progress_state.bsa_building_total > 0 and progress_state.bsa_building_current > 0:
|
|
bsa_percent = (progress_state.bsa_building_current / progress_state.bsa_building_total) * 100.0
|
|
progress_state.overall_percent = min(99.0, bsa_percent) # Cap at 99% until fully complete
|
|
|
|
# CRITICAL: Detect stalled downloads (0.0MB/s for extended period)
|
|
# Catch silent token refresh failures or network issues
|
|
# IMPORTANT: Only check during DOWNLOAD phase, not during VALIDATE phase
|
|
# Validation checks existing files and shows 0.0MB/s, which is expected behavior
|
|
if progress_state.phase == InstallationPhase.DOWNLOAD:
|
|
speed_display = progress_state.get_overall_speed_display()
|
|
# Check if speed is 0 or very low (< 0.1MB/s) for more than 2 minutes
|
|
# Only trigger if we're actually in download phase (not validation)
|
|
is_stalled = not speed_display or speed_display == "0.0B/s" or \
|
|
(speed_display and any(x in speed_display.lower() for x in ['0.0mb/s', '0.0kb/s', '0b/s']))
|
|
|
|
# Additional check: Only consider it stalled if we have active download files
|
|
# If no files are being downloaded, it might just be between downloads
|
|
has_active_downloads = any(
|
|
f.operation == OperationType.DOWNLOAD and not f.is_complete
|
|
for f in progress_state.active_files
|
|
)
|
|
|
|
if is_stalled and has_active_downloads:
|
|
if self._stalled_download_start_time is None:
|
|
self._stalled_download_start_time = time.time()
|
|
self._stalled_data_snapshot = progress_state.data_processed
|
|
elif progress_state.data_processed > self._stalled_data_snapshot:
|
|
# Bytes are advancing despite 0 speed readout — engine reporting lag, not a real stall
|
|
self._stalled_download_start_time = time.time()
|
|
self._stalled_data_snapshot = progress_state.data_processed
|
|
else:
|
|
stalled_duration = time.time() - self._stalled_download_start_time
|
|
# Warn after 2 minutes of stalled downloads
|
|
if stalled_duration > 120 and not self._stalled_download_notified:
|
|
self._stalled_download_notified = True
|
|
logger.warning("Downloads stalled (0.0MB/s for 2+ minutes)")
|
|
MessageService.warning(
|
|
self,
|
|
"Download Stalled",
|
|
(
|
|
"Downloads have been stalled (0.0MB/s) for over 2 minutes.\n\n"
|
|
"Possible causes:\n"
|
|
"• OAuth token expired and refresh failed\n"
|
|
"• Network connectivity issues\n"
|
|
"• Nexus Mods server issues\n\n"
|
|
"Please check the console output (Show Details) for error messages.\n"
|
|
"If authentication failed, you may need to re-authorize in Settings."
|
|
),
|
|
safety_level="low"
|
|
)
|
|
# Force console to be visible
|
|
if not self.show_details_checkbox.isChecked():
|
|
self.show_details_checkbox.setChecked(True)
|
|
# Add warning to console
|
|
self._safe_append_text(
|
|
"\n[Jackify] WARNING: Downloads have stalled (0.0MB/s for 2+ minutes)\n"
|
|
"[Jackify] This may indicate an authentication or network issue.\n"
|
|
"[Jackify] Check the console above for error messages.\n"
|
|
)
|
|
else:
|
|
# Downloads are active - reset stall timer
|
|
self._stalled_download_start_time = None
|
|
self._stalled_download_notified = False
|
|
self._stalled_data_snapshot = 0
|
|
|
|
# Update progress indicator widget
|
|
self.progress_indicator.update_progress(progress_state)
|
|
|
|
# Only show file progress list if console is not visible (mutually exclusive)
|
|
console_visible = self.show_details_checkbox.isChecked()
|
|
|
|
# Determine phase display name up front (short/stable label)
|
|
phase_label = progress_state.get_phase_label()
|
|
|
|
# During installation or extraction phase, show summary counter instead of individual files
|
|
# Avoid cluttering UI with completed files
|
|
is_installation_phase = (
|
|
progress_state.phase == InstallationPhase.INSTALL or
|
|
(progress_state.phase_name and 'install' in progress_state.phase_name.lower())
|
|
)
|
|
is_extraction_phase = (
|
|
progress_state.phase == InstallationPhase.EXTRACT or
|
|
(progress_state.phase_name and 'extract' in progress_state.phase_name.lower())
|
|
)
|
|
|
|
# Detect BSA building phase - check multiple indicators
|
|
is_bsa_building = False
|
|
|
|
# Check phase name for BSA indicators
|
|
if progress_state.phase_name:
|
|
phase_lower = progress_state.phase_name.lower()
|
|
if 'bsa' in phase_lower or ('building' in phase_lower and progress_state.phase == InstallationPhase.INSTALL):
|
|
is_bsa_building = True
|
|
|
|
# Check message/status text for BSA building indicators
|
|
if not is_bsa_building and progress_state.message:
|
|
msg_lower = progress_state.message.lower()
|
|
if ('building' in msg_lower or 'writing' in msg_lower or 'verifying' in msg_lower) and '.bsa' in msg_lower:
|
|
is_bsa_building = True
|
|
|
|
# Check if we have BSA files being processed (even if they're at 100%, they indicate BSA phase)
|
|
if not is_bsa_building and progress_state.active_files:
|
|
bsa_files = [f for f in progress_state.active_files if f.filename.lower().endswith('.bsa')]
|
|
if len(bsa_files) > 0:
|
|
# If we have any BSA files and we're in INSTALL phase, likely BSA building
|
|
if progress_state.phase == InstallationPhase.INSTALL:
|
|
is_bsa_building = True
|
|
|
|
# Also check display text for BSA mentions (fallback)
|
|
if not is_bsa_building:
|
|
display_lower = progress_state.display_text.lower()
|
|
if 'bsa' in display_lower and progress_state.phase == InstallationPhase.INSTALL:
|
|
is_bsa_building = True
|
|
|
|
now_mono = time.monotonic()
|
|
if is_bsa_building:
|
|
self._bsa_hold_deadline = now_mono + 1.5
|
|
elif now_mono < self._bsa_hold_deadline:
|
|
is_bsa_building = True
|
|
else:
|
|
self._bsa_hold_deadline = now_mono
|
|
|
|
if is_installation_phase:
|
|
# During installation, we may have BSA building AND file installation happening
|
|
# Show both: install summary + any active BSA files
|
|
# Render loop handles smooth updates - just set target state
|
|
|
|
current_step = progress_state.phase_step
|
|
|
|
display_items = []
|
|
|
|
# Line 1: Always show "Installing Files: X/Y" at the top (no progress bar, no size)
|
|
if current_step > 0 or progress_state.phase_max_steps > 0:
|
|
install_line = FileProgress(
|
|
filename=f"Installing Files: {current_step}/{progress_state.phase_max_steps}",
|
|
operation=OperationType.INSTALL,
|
|
percent=0.0,
|
|
speed=-1.0
|
|
)
|
|
install_line._no_progress_bar = True # Flag to hide progress bar
|
|
display_items.append(install_line)
|
|
|
|
# Lines 2+: Show converting textures and BSA files
|
|
# Extract and categorize active files
|
|
for f in progress_state.active_files:
|
|
if f.operation == OperationType.INSTALL:
|
|
if f.filename.lower().endswith('.bsa') or f.filename.lower().endswith('.ba2'):
|
|
# BSA: filename.bsa (42/89) - Use state-level BSA counter
|
|
if progress_state.bsa_building_total > 0:
|
|
display_filename = f"BSA: {f.filename} ({progress_state.bsa_building_current}/{progress_state.bsa_building_total})"
|
|
else:
|
|
display_filename = f"BSA: {f.filename}"
|
|
|
|
display_file = FileProgress(
|
|
filename=display_filename,
|
|
operation=f.operation,
|
|
percent=f.percent,
|
|
current_size=0, # Don't show size
|
|
total_size=0,
|
|
speed=-1.0 # No speed
|
|
)
|
|
display_items.append(display_file)
|
|
if len(display_items) >= 4: # Max 1 install line + 3 operations
|
|
break
|
|
elif f.filename.lower().endswith(('.dds', '.png', '.tga', '.bmp')):
|
|
# Converting Texture: filename.dds (234/1078)
|
|
# Use state-level texture counter (more reliable than file-level)
|
|
if progress_state.texture_conversion_total > 0:
|
|
display_filename = f"Converting Texture: {f.filename} ({progress_state.texture_conversion_current}/{progress_state.texture_conversion_total})"
|
|
else:
|
|
# No texture counter available, just show filename
|
|
display_filename = f"Converting Texture: {f.filename}"
|
|
|
|
display_file = FileProgress(
|
|
filename=display_filename,
|
|
operation=f.operation,
|
|
percent=f.percent,
|
|
current_size=0, # Don't show size
|
|
total_size=0,
|
|
speed=-1.0 # No speed
|
|
)
|
|
display_items.append(display_file)
|
|
if len(display_items) >= 4: # Max 1 install line + 3 operations
|
|
break
|
|
|
|
# Update target state (render loop handles smooth display)
|
|
# Explicitly pass None for summary_info to clear any stale summary data
|
|
if display_items:
|
|
self.file_progress_list.update_files(display_items, current_phase="Installing", summary_info=None)
|
|
return
|
|
elif is_extraction_phase:
|
|
# Show summary info for Extracting phase (step count)
|
|
# Render loop handles smooth updates - just set target state
|
|
# Explicitly pass empty list for file_progresses to clear any stale file list
|
|
current_step = progress_state.phase_step
|
|
summary_info = {
|
|
'current_step': current_step,
|
|
'max_steps': progress_state.phase_max_steps,
|
|
}
|
|
phase_display_name = phase_label or "Extracting"
|
|
self.file_progress_list.update_files([], current_phase=phase_display_name, summary_info=summary_info)
|
|
return
|
|
elif progress_state.active_files:
|
|
if self.debug:
|
|
logger.debug(f"DEBUG: Updating file progress list with {len(progress_state.active_files)} files")
|
|
for fp in progress_state.active_files:
|
|
logger.debug(f"DEBUG: - {fp.filename}: {fp.percent:.1f}% ({fp.operation.value})")
|
|
# Pass phase label to update header (e.g., "[Activity - Downloading]")
|
|
# Explicitly clear summary_info when showing file list
|
|
try:
|
|
self.file_progress_list.update_files(progress_state.active_files, current_phase=phase_label, summary_info=None)
|
|
except RuntimeError as e:
|
|
# Widget was deleted - ignore to prevent coredump
|
|
if "already deleted" in str(e):
|
|
if self.debug:
|
|
logger.debug(f"DEBUG: Ignoring widget deletion error: {e}")
|
|
return
|
|
raise
|
|
except Exception as e:
|
|
# Catch any other exceptions to prevent coredump
|
|
if self.debug:
|
|
logger.debug(f"DEBUG: Error updating file progress list: {e}")
|
|
import logging
|
|
logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True)
|
|
else:
|
|
# Show empty state so widget stays visible even when no files are active
|
|
try:
|
|
self.file_progress_list.update_files([], current_phase=phase_label)
|
|
except RuntimeError as e:
|
|
# Widget was deleted - ignore to prevent coredump
|
|
if "already deleted" in str(e):
|
|
return
|
|
raise
|
|
except Exception as e:
|
|
# Catch any other exceptions to prevent coredump
|
|
import logging
|
|
logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True)
|
|
|
|
def on_installation_finished(self, success, message):
|
|
"""Handle installation completion"""
|
|
logger.debug(f"DEBUG: on_installation_finished called with success={success}, message={message}")
|
|
# R&D: Clear all progress displays when installation completes
|
|
self.progress_state_manager.reset()
|
|
# Clear file list but keep CPU tracking running for configuration phase
|
|
self.file_progress_list.list_widget.clear()
|
|
self.file_progress_list._file_items.clear()
|
|
self.file_progress_list._summary_widget = None
|
|
self.file_progress_list._transition_label = None
|
|
self.file_progress_list._last_phase = None
|
|
|
|
if success:
|
|
# Update progress indicator with completion
|
|
final_state = InstallationProgress(
|
|
phase=InstallationPhase.FINALIZE,
|
|
phase_name="Installation Complete",
|
|
overall_percent=100.0
|
|
)
|
|
self.progress_indicator.update_progress(final_state)
|
|
|
|
try:
|
|
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}")
|
|
|
|
logger.info(f"Installation succeeded: {message}")
|
|
if self.show_details_checkbox.isChecked():
|
|
self._safe_append_text(f"\nSuccess: {message}")
|
|
self.process_finished(0, QProcess.NormalExit) # Simulate successful completion
|
|
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."
|
|
|
|
if not self._premium_failure_active and not cancellation_detected:
|
|
thread = getattr(self, 'install_thread', None)
|
|
if (thread
|
|
and not getattr(thread, '_install_progress_started', False)
|
|
and getattr(getattr(thread, 'last_error', None), 'title', '') == "Disk Full"):
|
|
ctx = getattr(thread, '_last_error_raw_context', {})
|
|
if self._handle_preflight_disk_space(ctx):
|
|
return
|
|
self._installation_cancelled = True
|
|
self.process_finished(130, QProcess.NormalExit)
|
|
return
|
|
|
|
if not self._premium_failure_active:
|
|
engine_error = getattr(self.install_thread, 'last_error', None)
|
|
if engine_error:
|
|
self._engine_error = engine_error
|
|
self._failure_message = message
|
|
|
|
logger.error(f"Installation failed: {message}")
|
|
if self.show_details_checkbox.isChecked():
|
|
self._safe_append_text(f"\nError: {message}")
|
|
self.process_finished(1, QProcess.CrashExit) # Simulate error
|
|
|
|
def _handle_preflight_disk_space(self, ctx: dict) -> bool:
|
|
"""Show pre-flight disk space warning dialog. Returns True if user chose Continue Anyway."""
|
|
required_bytes = ctx.get('required_bytes', 0)
|
|
available_bytes = ctx.get('available_bytes', 0)
|
|
|
|
def _fmt(b):
|
|
if b >= 1024 ** 3:
|
|
return f"{b / 1024 ** 3:.1f} GB"
|
|
if b >= 1024 ** 2:
|
|
return f"{b / 1024 ** 2:.1f} MB"
|
|
return f"{b} bytes" if b else "unknown"
|
|
|
|
required_str = _fmt(required_bytes)
|
|
available_str = _fmt(available_bytes)
|
|
|
|
body = (
|
|
f"The disk space check reports that there may not be enough free space to complete "
|
|
f"this installation.\n\n"
|
|
f"Required: {required_str}\n"
|
|
f"Available: {available_str}\n\n"
|
|
f"If this is a modlist update, the actual space needed is likely far less — most files "
|
|
f"are already present and will be reused rather than re-downloaded.\n\n"
|
|
f"You can continue and free up space while downloads are running, "
|
|
f"or cancel to resolve the space issue first."
|
|
)
|
|
|
|
from PySide6.QtWidgets import QMessageBox
|
|
dlg = QMessageBox(self)
|
|
dlg.setWindowTitle("Disk Space Warning")
|
|
dlg.setText("Not enough free disk space detected.")
|
|
dlg.setInformativeText(body)
|
|
dlg.setIcon(QMessageBox.Warning)
|
|
continue_btn = dlg.addButton("Continue Anyway", QMessageBox.AcceptRole)
|
|
dlg.addButton("Cancel", QMessageBox.RejectRole)
|
|
dlg.setDefaultButton(continue_btn)
|
|
dlg.exec()
|
|
|
|
if dlg.clickedButton() is not continue_btn:
|
|
return False
|
|
|
|
thread = getattr(self, 'install_thread', None)
|
|
if not thread:
|
|
return False
|
|
|
|
modlist = getattr(thread, 'modlist', None)
|
|
install_dir = getattr(thread, 'install_dir', None)
|
|
downloads_dir = getattr(thread, 'downloads_dir', None)
|
|
api_key = getattr(thread, 'api_key', None)
|
|
install_mode = getattr(thread, 'install_mode', 'online')
|
|
oauth_info = getattr(thread, 'oauth_info', None)
|
|
|
|
if not (modlist and install_dir and downloads_dir and api_key):
|
|
return False
|
|
|
|
logger.info("Pre-flight disk space check bypassed by user — restarting with --skip-disk-check")
|
|
self._safe_append_text("\n[WARN] Disk space check bypassed. Continuing installation...\n")
|
|
self.run_modlist_installer(
|
|
modlist, install_dir, downloads_dir, api_key,
|
|
install_mode, oauth_info, skip_disk_check=True,
|
|
)
|
|
return True
|
|
|
|
def process_finished(self, exit_code, exit_status):
|
|
logger.debug(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}")
|
|
# Reset button states
|
|
self.start_btn.setEnabled(True)
|
|
self.cancel_btn.setVisible(True)
|
|
self.cancel_install_btn.setVisible(False)
|
|
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)
|
|
|
|
if game_type and not self.wabbajack_parser.is_supported_game(game_type):
|
|
# Show success message for unsupported games without post-install configuration
|
|
MessageService.information(
|
|
self, "Modlist Install Complete!",
|
|
f"Modlist installation completed successfully!\n\n"
|
|
f"Note: Post-install configuration was skipped for unsupported game type: {game_name or game_type}\n\n"
|
|
f"You will need to manually configure Steam shortcuts and other post-install steps."
|
|
)
|
|
logger.warning(f"Post-install configuration skipped for unsupported game: {game_name or game_type}")
|
|
self._safe_append_text(f"\nModlist installation completed successfully.")
|
|
self._safe_append_text(f"\nWarning: Post-install configuration skipped for unsupported game: {game_name or game_type}")
|
|
else:
|
|
# Check if auto-restart is enabled
|
|
auto_restart_enabled = hasattr(self, 'auto_restart_checkbox') and self.auto_restart_checkbox.isChecked()
|
|
|
|
if auto_restart_enabled:
|
|
# Auto-accept Steam restart - proceed without dialog
|
|
logger.info("Auto-accepting Steam restart (unattended mode enabled)")
|
|
reply = QMessageBox.Yes # Simulate user clicking Yes
|
|
else:
|
|
# Show the normal install complete dialog for supported games
|
|
reply = MessageService.question(
|
|
self, "Modlist Install Complete!",
|
|
"Modlist install complete!\n\nWould you like to add this modlist to Steam and configure it now? Steam will restart, closing any game you have open!",
|
|
critical=False, # Non-critical, won't steal focus
|
|
safety_level="medium",
|
|
)
|
|
|
|
if reply == QMessageBox.Yes:
|
|
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!")
|
|
self._safe_append_text("Note: You can manually configure Steam integration later if needed.")
|
|
MessageService.information(
|
|
self, "Installation Complete",
|
|
"Modlist installation completed successfully!\n\n"
|
|
"The modlist has been installed but Steam integration was skipped.\n"
|
|
"You can manually add the modlist to Steam later if desired.",
|
|
safety_level="medium"
|
|
)
|
|
# Re-enable controls since operation is complete
|
|
self._enable_controls_after_operation()
|
|
else:
|
|
# Check for user cancellation first - check message parameter first, then console
|
|
if self._premium_failure_active:
|
|
MessageService.warning(
|
|
self,
|
|
"Nexus Premium Required",
|
|
"Jackify stopped the installation because Nexus Mods reported that this account is not Premium.\n\n"
|
|
"Automatic installs currently require Nexus Premium. Non-premium support is planned.",
|
|
safety_level="medium"
|
|
)
|
|
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")
|
|
self._cancellation_requested = False
|
|
else:
|
|
# Check console as fallback
|
|
last_output = self.console.toPlainText()
|
|
if "cancelled by user" in last_output.lower():
|
|
MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low")
|
|
else:
|
|
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 "Install failed, but no specific error details were captured from engine output."
|
|
)
|
|
self._failure_message = None
|
|
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)
|