Release v0.7.2.1 - Mojave Express Support, BSA Decompressor Fix

This commit is contained in:
Omni
2026-07-25 13:26:02 +01:00
parent ada21f90c8
commit 22bf5e30e5
21 changed files with 1488 additions and 169 deletions
@@ -130,6 +130,40 @@ class ConfigureModlistCommand:
logger.error("VNV post-install automation failed: %s", e, exc_info=True)
print(f"{COLOR_WARNING}VNV automation could not be completed. Check logs for details.{COLOR_RESET}")
# MEW
try:
from jackify.backend.services.mew_integration_helper import (
run_mew_automation_if_applicable,
should_offer_mew_automation,
)
if should_offer_mew_automation(modlist_name, Path(install_dir)):
def _confirm_mew(description: str) -> bool:
print(f"\n{description}\n")
try:
ans = input(f"{COLOR_PROMPT}Run MEW post-install automation now? (Y/n): {COLOR_RESET}").strip().lower()
except (EOFError, KeyboardInterrupt):
return False
return ans in ("", "y", "yes")
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
automation_ran, mew_error = run_mew_automation_if_applicable(
modlist_name=modlist_name,
modlist_install_location=Path(install_dir),
game_root=None,
appid=app_id,
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=print,
manual_file_callback=None,
confirmation_callback=_confirm_mew,
)
if automation_ran and not mew_error:
print(f"{COLOR_INFO}MEW post-install automation completed.{COLOR_RESET}")
if mew_error:
print(f"{COLOR_WARNING}MEW automation encountered an error: {mew_error}{COLOR_RESET}")
except Exception as e:
logger.error("MEW post-install automation failed: %s", e, exc_info=True)
print(f"{COLOR_WARNING}MEW automation could not be completed. Check logs for details.{COLOR_RESET}")
# JContainers
try:
from jackify.backend.handlers.modlist_fixup_handler import (
@@ -0,0 +1,135 @@
"""
Game Proton Warning Dialog
Shown to recommend a Proton version for the modlist's game type, mirroring
enb_proton_dialog.py's structure and behavior.
"""
import logging
from pathlib import Path
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QSizePolicy, QFrame
)
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QIcon
logger = logging.getLogger(__name__)
class GameProtonWarningDialog(QDialog):
"""Dialog recommending a Proton version for the modlist's game."""
def __init__(self, modlist_name: str, warning: dict, game_label: str, parent=None):
super().__init__(parent)
self.modlist_name = modlist_name
self.setWindowTitle("Recommended Proton Version")
self.setWindowModality(Qt.ApplicationModal)
self.setFixedSize(600, 420)
self.setStyleSheet("QDialog { background: #181818; color: #fff; border-radius: 12px; }")
layout = QVBoxLayout(self)
layout.setSpacing(0)
layout.setContentsMargins(30, 30, 30, 30)
card = QFrame(self)
card.setObjectName("gameProtonCard")
card.setFrameShape(QFrame.StyledPanel)
card.setFrameShadow(QFrame.Raised)
card.setFixedWidth(540)
card.setMinimumHeight(280)
card.setMaximumHeight(16777215)
card_layout = QVBoxLayout(card)
card_layout.setSpacing(16)
card_layout.setContentsMargins(28, 28, 28, 28)
card.setStyleSheet(
"QFrame#gameProtonCard { "
" background: #23272e; "
" border-radius: 12px; "
" border: 2px solid #3fb7d6;"
"}"
)
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding)
title_label = QLabel("Recommended Proton Version")
title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet(
"QLabel { font-size: 24px; font-weight: 700; color: #3fb7d6; margin-bottom: 4px; }"
)
card_layout.addWidget(title_label)
warning_label = QLabel(
f"The following Proton versions are recommended for {game_label}:"
)
warning_label.setAlignment(Qt.AlignCenter)
warning_label.setWordWrap(True)
warning_label.setStyleSheet(
"QLabel { font-size: 14px; color: #e0e0e0; line-height: 1.5; margin-bottom: 12px; padding: 8px; }"
)
card_layout.addWidget(warning_label)
recommended = warning.get("recommended", [])
recommended_html = "<br/>".join(f"- <b style='color: #3fd0ea;'>{v}</b>" for v in recommended)
details_text = (
"<div style='text-align: left; padding: 12px; background: #1a1d23; border-radius: 8px; margin: 8px 0;'>"
"<div style='font-size: 13px; color: #b0b0b0; margin-bottom: 8px;'><b style='color: #fff;'>(In order of recommendation)</b></div>"
f"<div style='font-size: 14px; color: #fff; line-height: 1.8;'>{recommended_html}</div>"
"</div>"
)
details_label = QLabel(details_text)
details_label.setAlignment(Qt.AlignLeft)
details_label.setWordWrap(True)
details_label.setStyleSheet(
"QLabel { font-size: 14px; color: #e0e0e0; line-height: 1.6; margin: 8px 0; }"
)
details_label.setTextFormat(Qt.RichText)
card_layout.addWidget(details_label)
layout.addStretch()
layout.addWidget(card, alignment=Qt.AlignCenter)
layout.addSpacing(20)
btn_row = QHBoxLayout()
btn_row.addStretch()
self.ok_btn = QPushButton("I Understand (3s)")
self.ok_btn.setEnabled(False)
self.ok_btn.setStyleSheet(
"QPushButton { background: #3fb7d6; color: #fff; border: none; border-radius: 6px; "
"padding: 10px 24px; font-size: 14px; font-weight: 600; }"
"QPushButton:hover { background: #35a5c2; }"
"QPushButton:pressed { background: #2d8fa8; }"
"QPushButton:disabled { background: #555; color: #aaa; }"
)
self.ok_btn.clicked.connect(self.accept)
btn_row.addWidget(self.ok_btn)
self._protect_countdown = 3
self._protect_timer = QTimer(self)
self._protect_timer.setInterval(1000)
self._protect_timer.timeout.connect(self._on_protect_tick)
self._protect_timer.start()
btn_row.addStretch()
layout.addLayout(btn_row)
self._set_dialog_icon()
logger.info(f"GameProtonWarningDialog created for modlist: {modlist_name}")
def _on_protect_tick(self):
self._protect_countdown -= 1
if self._protect_countdown > 0:
self.ok_btn.setText(f"I Understand ({self._protect_countdown}s)")
else:
self._protect_timer.stop()
self.ok_btn.setText("I Understand")
self.ok_btn.setEnabled(True)
def _set_dialog_icon(self):
try:
icon_path = Path(__file__).parent.parent.parent.parent.parent / "Files" / "wabbajack-icon.png"
if icon_path.exists():
icon = QIcon(str(icon_path))
self.setWindowIcon(icon)
except Exception as e:
logger.debug(f"Could not set dialog icon: {e}")
@@ -134,8 +134,11 @@ class ConfigureExistingModlistScreen(
self._initiate_ttw_workflow(identified_name, install_dir)
return
# Check for VNV post-install automation after configuration
if install_dir and self._check_and_run_vnv_automation(modlist_name, install_dir):
# Check for VNV/MEW post-install automation after configuration
if install_dir and (
self._check_and_run_vnv_automation(modlist_name, install_dir)
or self._check_and_run_mew_automation(modlist_name, install_dir)
):
self._pending_success_dialog_params = {
'modlist_name': modlist_name,
'workflow_type': 'configure_existing',
@@ -309,6 +309,58 @@ class ConfigureExistingModlistWorkflowMixin:
},
)
def _check_and_run_mew_automation(self, modlist_name: str, install_dir: str) -> bool:
"""Check if MEW automation should run and start it if applicable.
Returns:
True if MEW automation is starting (caller should defer success dialog)
False if no MEW needed (show success dialog immediately)
"""
from ..services.mew_automation_controller import MEWAutomationController
self._mew_controller = MEWAutomationController()
return self._mew_controller.attempt(
parent=self,
modlist_name=modlist_name,
install_dir=install_dir,
appid=getattr(self, '_current_appid', None),
on_progress=self._safe_append_text,
on_complete=self._on_mew_complete,
begin_feedback=self._begin_post_install_feedback,
handle_feedback=self._handle_post_install_progress,
)
def _on_mew_complete(self, success: bool, error: str):
"""Handle MEW 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,
"MEW Automation Failed",
f"MEW post-install automation encountered an error:\n\n{error}\n\n"
"You can complete these steps manually by following the guide at:\n"
"https://mojaveexpressguide.com/docs/Installation"
)
elif success:
self._safe_append_text("MEW post-install automation completed successfully.")
if hasattr(self, '_pending_success_dialog_params'):
params = self._pending_success_dialog_params
del self._pending_success_dialog_params
self._run_verifier_then_show_success(
install_dir=params.get('install_dir', ''),
game_type=params.get('game_type', 'unknown'),
appid=params.get('appid', ''),
success_params={
'modlist_name': params['modlist_name'],
'workflow_type': params['workflow_type'],
'time_taken': params['time_taken'],
'game_name': params.get('game_name'),
'enb_detected': params.get('enb_detected', False),
},
)
def show_manual_steps_dialog(self, extra_warning=""):
modlist_name = self.shortcut_combo.currentText().split('(')[0].strip() or "your modlist"
msg = (
@@ -79,8 +79,11 @@ class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegr
self._initiate_ttw_workflow(identified_name, install_dir)
return
# Check for VNV post-install automation after configuration
if install_dir and self._check_and_run_vnv_automation(modlist_name, install_dir):
# Check for VNV/MEW post-install automation after configuration
if install_dir and (
self._check_and_run_vnv_automation(modlist_name, install_dir)
or self._check_and_run_mew_automation(modlist_name, install_dir)
):
self._pending_success_dialog_params = {
'modlist_name': modlist_name,
'workflow_type': 'configure_new',
@@ -245,6 +245,63 @@ class ConfigureNewModlistDialogsMixin:
},
)
def _check_and_run_mew_automation(self, modlist_name: str, install_dir: str) -> bool:
"""Check if MEW automation should run and start it if applicable.
Returns:
True if MEW automation is starting (caller should defer success dialog)
False if no MEW needed (show success dialog immediately)
"""
from ..services.mew_automation_controller import MEWAutomationController
_ctx = getattr(self, 'context', None)
_appid = getattr(self, '_current_appid', None) or (
_ctx.get('appid') if isinstance(_ctx, dict) else None
)
self._mew_controller = MEWAutomationController()
return self._mew_controller.attempt(
parent=self,
modlist_name=modlist_name,
install_dir=install_dir,
appid=_appid,
on_progress=self._safe_append_text,
on_complete=self._on_mew_complete,
begin_feedback=self._begin_post_install_feedback,
handle_feedback=self._handle_post_install_progress,
)
def _on_mew_complete(self, success: bool, error: str):
"""Handle MEW 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,
"MEW Automation Failed",
f"MEW post-install automation encountered an error:\n\n{error}\n\n"
"You can complete these steps manually by following the guide at:\n"
"https://mojaveexpressguide.com/docs/Installation"
)
elif success:
self._safe_append_text("MEW post-install automation completed successfully.")
if hasattr(self, '_pending_success_dialog_params'):
params = self._pending_success_dialog_params
del self._pending_success_dialog_params
self._run_verifier_then_show_success(
install_dir=params.get('install_dir', ''),
game_type=params.get('game_type', 'unknown'),
appid=params.get('appid', ''),
success_params={
'modlist_name': params['modlist_name'],
'workflow_type': params['workflow_type'],
'time_taken': params['time_taken'],
'game_name': params.get('game_name'),
'enb_detected': params.get('enb_detected', False),
},
)
def show_next_steps_dialog(self, message):
dlg = QDialog(self)
dlg.setWindowTitle("Next Steps")
@@ -45,6 +45,7 @@ from .install_modlist_automated_prefix import AutomatedPrefixHandlersMixin
from .install_modlist_configuration import ConfigurationPhaseMixin
from .install_modlist_ttw import TTWIntegrationMixin
from .install_modlist_vnv import VNVAutomationMixin
from .install_modlist_mew import MEWAutomationMixin
from .install_modlist_workflow import InstallWorkflowMixin
from .install_modlist_nexus import NexusAuthMixin
from .install_modlist_selection import ModlistSelectionMixin
@@ -52,7 +53,7 @@ from .screen_back_mixin import ScreenBackMixin
from .install_verifier_mixin import InstallVerifierMixin
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifierMixin, InstallModlistUISetupMixin, ConsoleOutputMixin, ProgressHandlersMixin, PostInstallFeedbackMixin, AutomatedPrefixHandlersMixin, ConfigurationPhaseMixin, QWidget, TTWIntegrationMixin, VNVAutomationMixin, InstallWorkflowMixin, NexusAuthMixin, ModlistSelectionMixin):
class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifierMixin, InstallModlistUISetupMixin, ConsoleOutputMixin, ProgressHandlersMixin, PostInstallFeedbackMixin, AutomatedPrefixHandlersMixin, ConfigurationPhaseMixin, QWidget, TTWIntegrationMixin, VNVAutomationMixin, MEWAutomationMixin, InstallWorkflowMixin, NexusAuthMixin, ModlistSelectionMixin):
resize_request = Signal(str) # Signal for expand/collapse like TTW screen
def _collect_actionable_controls(self):
"""Collect all actionable controls that should be disabled during operations (except Cancel)"""
@@ -142,8 +142,10 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
self._initiate_ttw_workflow(ttw_modlist_name, install_dir)
return # Don't show success dialog yet, will show after TTW completes
# Check for VNV post-install automation after TTW check
# Check for VNV/MEW post-install automation after TTW check
vnv_automation_running = self._check_and_run_vnv_automation(modlist_name, install_dir)
if not vnv_automation_running:
vnv_automation_running = self._check_and_run_mew_automation(modlist_name, install_dir)
if vnv_automation_running:
self._cleanup_config_thread()
@@ -0,0 +1,71 @@
"""MEW automation methods for InstallModlistScreen (Mixin).
Delegates to MEWAutomationController for the actual workflow.
Mirrors install_modlist_vnv.py.
"""
import logging
logger = logging.getLogger(__name__)
class MEWAutomationMixin:
"""Mixin providing MEW automation methods for InstallModlistScreen."""
def _check_and_run_mew_automation(self, modlist_name: str, install_dir: str) -> bool:
"""Check if MEW automation should run and start it if applicable.
Returns:
True if MEW automation is starting (success dialog should be deferred)
False if no MEW automation needed (show success dialog immediately)
"""
from ..services.mew_automation_controller import MEWAutomationController
_ctx = getattr(self, 'context', None)
_appid = getattr(self, '_current_appid', None) or (
_ctx.get('appid') if isinstance(_ctx, dict) else None
)
self._mew_controller = MEWAutomationController()
return self._mew_controller.attempt(
parent=self,
modlist_name=modlist_name,
install_dir=install_dir,
appid=_appid,
on_progress=self._safe_append_text,
on_complete=self._on_mew_complete,
begin_feedback=self._begin_post_install_feedback,
handle_feedback=self._handle_post_install_progress,
)
def _on_mew_complete(self, success: bool, error: str):
"""Handle MEW 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,
"MEW Automation Failed",
f"MEW post-install automation encountered an error:\n\n{error}\n\n"
"You can complete these steps manually by following the guide at:\n"
"https://mojaveexpressguide.com/docs/Installation"
)
elif success:
self._safe_append_text("MEW post-install automation completed successfully")
if hasattr(self, '_pending_success_dialog_params'):
params = self._pending_success_dialog_params
del self._pending_success_dialog_params
self._run_verifier_then_show_success(
install_dir=params.get('install_dir', ''),
game_type=params.get('game_type', 'unknown'),
appid=params.get('appid', ''),
success_params={
'modlist_name': params['modlist_name'],
'workflow_type': params.get('workflow_type', 'install'),
'time_taken': params['time_taken'],
'game_name': params.get('game_name'),
'enb_detected': params.get('enb_detected', False),
},
)
@@ -152,7 +152,7 @@ class PostInstallFeedbackMixin:
},
{
'id': 'vnv_root_mods',
'label': "VNV: Copying root mods",
'label': "Copying root mods",
'keywords': [
"step 1/3: copying root mods",
"copying root mods to game directory",
@@ -161,7 +161,7 @@ class PostInstallFeedbackMixin:
},
{
'id': 'vnv_4gb_patch',
'label': "VNV: Applying 4GB patch",
'label': "Applying 4GB patch",
'keywords': [
"step 2/3: downloading and running 4gb patcher",
"downloading fnv4gb",
@@ -173,7 +173,7 @@ class PostInstallFeedbackMixin:
},
{
'id': 'vnv_bsa_decompress',
'label': "VNV: Decompressing BSA files",
'label': "Decompressing BSA files",
'keywords': [
"step 3/3: downloading and running bsa decompressor",
"downloading:",
@@ -206,7 +206,7 @@ class PostInstallFeedbackMixin:
"configuration complete",
"manual steps validation failed",
"configuration failed",
"vnv post-install completed successfully"
"post-install completed",
],
},
]
@@ -557,14 +557,14 @@ class PostInstallFeedbackMixin:
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)
self.file_progress_list.update_or_add_item("__vnv_bsa__", "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)
self.file_progress_list.update_or_add_item("__vnv_bsa__", "Decompressing BSA files...", 0.0)
def _stop_bsa_decompress_pulse(self):
if hasattr(self, '_bsa_decompress_timer') and self._bsa_decompress_timer:
@@ -148,8 +148,9 @@ class InstallVerifierMixin:
Show 'Verifying...' state, run the verifier in a background thread,
then show SuccessDialog with results embedded.
success_params keys: modlist_name, workflow_type, time_taken, game_name, enb_detected
success_params keys: modlist_name, workflow_type, time_taken, game_name, enb_detected, game_type
"""
success_params["game_type"] = game_type
self._maybe_apply_jcontainers_fix(install_dir, game_type)
self._apply_problem_mods_disable(install_dir, game_type, success_params, appid)
if hasattr(self, "progress_indicator"):
@@ -230,3 +231,18 @@ class InstallVerifierMixin:
enb_dialog.exec()
except Exception as e:
logger.warning("Failed to show ENB dialog: %s", e)
try:
from jackify.backend.data.modlist_proton_requirements import get_game_proton_warning
_game_warning = get_game_proton_warning(params.get("game_type", ""))
if _game_warning:
from jackify.frontends.gui.dialogs.game_proton_warning_dialog import GameProtonWarningDialog
game_proton_dialog = GameProtonWarningDialog(
modlist_name=params["modlist_name"],
warning=_game_warning,
game_label=params.get("game_name") or "this game",
parent=self,
)
game_proton_dialog.exec()
except Exception as e:
logger.warning("Failed to show game Proton warning dialog: %s", e)
@@ -0,0 +1,395 @@
"""
Shared MEW post-install automation controller for all GUI workflows.
Mirrors vnv_automation_controller.py. Handles MEW detection, user confirmation,
premium/non-premium download paths (shared with VNV via MEWPostInstallService.fnv_tools),
worker thread management, and completion callbacks.
"""
import logging
import warnings
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__)
# Keep references to orphaned workers alive until they finish naturally.
_ORPHANED_WORKERS: set = set()
class _MEWWorker(QThread):
"""Background thread for MEW automation."""
progress_update = Signal(str)
completed = Signal(bool, str) # (success, error_message)
def __init__(self, modlist_name, install_path, game_root, appid, ttw_installer_path):
super().__init__()
self._modlist_name = modlist_name
self._install_path = install_path
self._game_root = game_root
self._appid = appid
self._ttw_installer_path = ttw_installer_path
def run(self):
try:
from jackify.backend.services.mew_integration_helper import run_mew_automation_if_applicable
automation_ran, error = run_mew_automation_if_applicable(
modlist_name=self._modlist_name,
modlist_install_location=self._install_path,
game_root=self._game_root,
appid=self._appid,
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 MEWAutomationController(QObject):
"""
Single entry point for MEW post-install automation across all GUI workflows.
Usage in any screen's on_configuration_complete:
from ..services.mew_automation_controller import MEWAutomationController
controller = MEWAutomationController()
if controller.attempt(
parent=self,
modlist_name=modlist_name,
install_dir=install_dir,
appid=appid,
on_progress=self._safe_append_text,
on_complete=lambda success, error: self._on_mew_done(success, error),
):
# MEW is running, defer success dialog
return
# No MEW, show success dialog now
"""
_worker_start_requested = Signal()
def __init__(self):
super().__init__()
self._worker: Optional[_MEWWorker] = 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,
appid: Optional[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 MEW eligibility and start automation if applicable.
Returns:
True if MEW automation is starting (caller should defer success dialog)
False if no MEW needed (caller should show success dialog immediately)
"""
try:
from jackify.backend.services.mew_integration_helper import should_offer_mew_automation
from jackify.backend.handlers.path_handler import PathHandler
from jackify.backend.services.mew_post_install_service import MEWPostInstallService
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
install_path = Path(install_dir)
if not should_offer_mew_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("MEW automation skipped - FNV game root not found")
on_progress("MEW automation skipped: Fallout New Vegas path not found")
return False
mew_service = MEWPostInstallService(
modlist_install_location=install_path,
game_root=game_root,
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
)
completed = mew_service.check_already_completed()
if completed['root_mods'] and completed['4gb_patch'] and completed['bsa_decompressed'] and completed['radio_fix']:
logger.info("MEW automation steps already completed")
return False
from .message_service import MessageService
reply = MessageService.question(
parent,
"MEW Post-Install Automation",
mew_service.get_automation_description(),
critical=False,
safety_level="medium",
)
if reply != QMessageBox.Yes:
logger.info("User declined MEW automation")
on_progress("MEW automation skipped by user")
return False
ttw_installer_path = AutomatedPrefixService.get_ttw_installer_path()
# Non-premium path: route 4GB patcher/BSA decompressor downloads through
# ManualDownloadManager. These tools are FNV-wide (shared with VNV via
# mew_service.fnv_tools), not MEW-specific.
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")
)
fnv_tools = mew_service.fnv_tools
if not is_premium:
has_4gb_cache = fnv_tools._find_cached_4gb_patcher() is not None
has_bsa_cache = (
fnv_tools._find_cached_bsa_mpi() is not None or
fnv_tools._find_cached_bsa_package() is not None
)
if has_4gb_cache and has_bsa_cache:
logger.debug("MEW non-premium: required FNV tools already cached, proceeding to worker")
else:
tool_events = fnv_tools.get_manual_download_items(include_bsa=not has_bsa_cache)
logger.debug("MEW non-premium: tool_events=%d, cache_dir=%s", len(tool_events), fnv_tools.cache_dir)
if tool_events:
if begin_feedback:
begin_feedback()
self._show_tool_download_dialog(
parent, tool_events, fnv_tools.cache_dir,
modlist_name, install_path, game_root, appid, ttw_installer_path,
on_progress, on_complete, handle_feedback,
)
return True
else:
logger.warning("MEW non-premium: Nexus API query failed, cannot open download manager")
try:
import subprocess
import os
_strip = {"LD_LIBRARY_PATH", "LD_PRELOAD", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH", "PYTHONPATH", "PYTHONHOME"}
clean_env = {k: v for k, v in os.environ.items() if k not in _strip}
subprocess.Popen(['xdg-open', 'https://www.nexusmods.com/newvegas/mods/62552?tab=files'], env=clean_env, start_new_session=True)
except Exception:
pass
from .message_service import MessageService
MessageService.information(
parent,
"MEW 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 MEW 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 {fnv_tools.cache_dir}\n\n"
"4. Re-configure the modlist - Jackify will detect the files automatically.",
)
return False
if begin_feedback:
begin_feedback()
self._start_worker(
parent, modlist_name, install_path, game_root, appid,
ttw_installer_path, on_progress, on_complete, handle_feedback,
)
return True
except Exception as e:
logger.error("Failed to start MEW automation: %s", e)
import traceback
logger.error("Traceback: %s", traceback.format_exc())
return False
def _dispatch_worker_start(self):
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, appid, ttw_installer_path,
on_progress, on_complete, handle_feedback,
):
"""Show ManualDownloadDialog for the shared FNV 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):
self._pending_worker_start = lambda: self._finish_manual_download_flow(
state,
parent,
modlist_name,
install_path,
game_root,
appid,
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="MEW 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()
dialog.raise_()
dialog.activateWindow()
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,
appid,
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,
appid,
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:
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
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, appid,
ttw_installer_path, on_progress, on_complete, handle_feedback,
):
self._on_progress_cb = on_progress
self._on_complete_cb = on_complete
self._handle_feedback_cb = handle_feedback
self._worker = _MEWWorker(
modlist_name, install_path, game_root, appid, 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():
worker = self._worker
_ORPHANED_WORKERS.add(worker)
worker.finished.connect(lambda w=worker: _ORPHANED_WORKERS.discard(w))
self._worker = None