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
+10
View File
@@ -1,5 +1,15 @@
# Jackify Changelog
## v0.7.2.1 - Mojave Express Support, BSA Decompressor Fix
**Release Date:** 2026-07-25
### Mojave Express Wabbajack (MEW) Automation
Jackify can now automate MEW's post-install steps the same way it already does for Viva New Vegas - copying root mods, running the 4GB patcher and BSA decompressor, and applying the Radio Fix.
### Fixes
- Fixed BSA decompression failing for both VNV and MEW since the v0.7.2 TTW Linux Installer update, which changed how the tool is invoked.
- Fallout New Vegas modlists now get a Proton version recommendation on the success screen, matching the existing ENB recommendation.
## v0.7.2 - TTW Linux Installer 0.2.0
**Release Date:** 2026-07-18
+1 -1
View File
@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
Wabbajack modlists natively on Linux systems.
"""
__version__ = "0.7.2"
__version__ = "0.7.2.1"
@@ -856,6 +856,13 @@ class ModlistOperationsConfigurationCLIMixin:
print(f"{COLOR_INFO} - GE-Proton{COLOR_RESET}")
print(f"{COLOR_INFO} - Proton 9 (Valve){COLOR_RESET}")
print(f"{COLOR_WARNING} Valve Proton 10 has known ENB compatibility issues.{COLOR_RESET}")
from jackify.backend.data.modlist_proton_requirements import get_game_proton_warning
_game_warning = get_game_proton_warning(detected_game or '')
if _game_warning:
print(f"\n{COLOR_INFO}Recommended Proton versions for this game (in order of recommendation):{COLOR_RESET}")
for _version in _game_warning['recommended']:
print(f"{COLOR_INFO} - {_version}{COLOR_RESET}")
try:
# Ensure CLI install flow gets the same VNV automation behavior as GUI.
from jackify.backend.services.vnv_integration_helper import (
@@ -923,6 +930,75 @@ class ModlistOperationsConfigurationCLIMixin:
except Exception as vnv_err:
self.logger.error("VNV post-install automation failed: %s", vnv_err, exc_info=True)
print(f"{COLOR_WARNING}VNV automation could not be completed. Check logs for details.{COLOR_RESET}")
try:
# Ensure CLI install flow gets the same MEW automation behavior as GUI.
from jackify.backend.services.mew_integration_helper import (
run_mew_automation_if_applicable,
should_offer_mew_automation,
)
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
from jackify.backend.services.mew_post_install_service import MEWPostInstallService
from jackify.backend.handlers.path_handler import PathHandler
from jackify.frontends.cli.commands.vnv_manual_downloads import (
build_vnv_cli_manual_file_callback,
create_vnv_cli_progress_callback,
ensure_vnv_cli_manual_downloads,
)
modlist_name_for_mew = self.context.get('modlist_name') or shortcut_name or ""
def _confirm_mew(description: str) -> bool:
print(f"\n{description}\n")
try:
user_input = input(f"{COLOR_PROMPT}Run MEW post-install automation now? (Y/n): {COLOR_RESET}").strip().lower()
except (EOFError, KeyboardInterrupt):
return False
return user_input in ("", "y", "yes")
install_path_mew = Path(install_dir_str)
if should_offer_mew_automation(modlist_name_for_mew, install_path_mew):
game_paths = PathHandler().find_vanilla_game_paths()
resolved_game_root = game_paths.get('Fallout New Vegas')
mew_service = MEWPostInstallService(
modlist_install_location=install_path_mew,
game_root=resolved_game_root or install_path_mew,
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
)
completed = mew_service.check_already_completed()
all_mew_steps_done = (
completed['root_mods']
and completed['4gb_patch']
and completed['bsa_decompressed']
and completed['radio_fix']
)
if all_mew_steps_done:
print(f"{COLOR_INFO}MEW post-install steps are already complete.{COLOR_RESET}")
elif _confirm_mew(mew_service.get_automation_description()):
if not ensure_vnv_cli_manual_downloads(mew_service.fnv_tools, output_callback=print):
print(f"{COLOR_WARNING}MEW manual downloads were not completed. Skipping MEW automation.{COLOR_RESET}")
else:
progress_callback, close_progress = create_vnv_cli_progress_callback(print)
try:
automation_ran, mew_error = run_mew_automation_if_applicable(
modlist_name=modlist_name_for_mew,
modlist_install_location=install_path_mew,
game_root=None, # Auto-detect from modlist structure.
appid=str(app_id) if app_id else None,
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=progress_callback,
manual_file_callback=build_vnv_cli_manual_file_callback(mew_service.fnv_tools, output_callback=print),
confirmation_callback=lambda _description: True,
)
finally:
close_progress()
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}")
print(f"{COLOR_INFO}You can complete these steps manually by following: https://mojaveexpressguide.com/docs/Installation{COLOR_RESET}")
else:
print(f"{COLOR_INFO}MEW automation skipped by user.{COLOR_RESET}")
except Exception as mew_err:
self.logger.error("MEW post-install automation failed: %s", mew_err, exc_info=True)
print(f"{COLOR_WARNING}MEW automation could not be completed. Check logs for details.{COLOR_RESET}")
try:
# v0.4.0 contract: offer TTW flow for eligible FNV lists (e.g., Begin Again).
from jackify.backend.handlers.modlist_install_cli_ttw import prompt_ttw_if_eligible
@@ -1,4 +1,4 @@
"""Per-modlist Proton version requirements for ENB compatibility warnings."""
"""Per-modlist and per-game Proton version requirements/warnings."""
from typing import Optional
@@ -17,3 +17,21 @@ def get_proton_requirement(modlist_name: str) -> Optional[dict[str, str]]:
if not modlist_name:
return None
return MODLIST_PROTON_REQUIREMENTS.get(modlist_name.strip().lower())
# Keys are lowercase game_type identifiers (e.g. "falloutnv", "fallout_new_vegas").
# recommended: Proton builds recommended for this game, in order of recommendation
GAME_PROTON_WARNINGS: dict[str, dict] = {
"falloutnv": {
"recommended": ["GE-Proton10-14", "Proton Experimental (latest)", "Proton-CachyOS"],
},
"fallout_new_vegas": {
"recommended": ["GE-Proton10-14", "Proton Experimental (latest)", "Proton-CachyOS"],
},
}
def get_game_proton_warning(game_type: str) -> Optional[dict]:
if not game_type:
return None
return GAME_PROTON_WARNINGS.get(game_type.strip().lower())
@@ -642,6 +642,71 @@ class ModlistMenuHandler:
self.logger.debug(f"VNV automation check skipped: {e}")
# Not an error - just means VNV automation wasn't applicable
from jackify.backend.services.mew_integration_helper import (
run_mew_automation_if_applicable,
should_offer_mew_automation,
)
from jackify.backend.services.mew_post_install_service import MEWPostInstallService
try:
def _confirm_mew(description: str) -> bool:
print(f"\n{description}\n")
try:
user_input = input(f"{COLOR_PROMPT}Run MEW post-install automation now? (Y/n): {COLOR_RESET}").strip().lower()
except (EOFError, KeyboardInterrupt):
return False
return user_input in ("", "y", "yes")
if should_offer_mew_automation(modlist_name, modlist_path):
game_paths = PathHandler().find_vanilla_game_paths()
resolved_game_root = game_paths.get('Fallout New Vegas')
mew_service = MEWPostInstallService(
modlist_install_location=modlist_path,
game_root=resolved_game_root or modlist_path,
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
)
completed = mew_service.check_already_completed()
all_mew_steps_done = (
completed['root_mods']
and completed['4gb_patch']
and completed['bsa_decompressed']
and completed['radio_fix']
)
if all_mew_steps_done:
print(f"{COLOR_INFO}MEW post-install steps are already complete.{COLOR_RESET}")
elif _confirm_mew(mew_service.get_automation_description()):
if not ensure_vnv_cli_manual_downloads(mew_service.fnv_tools, output_callback=print):
print(f"{COLOR_WARNING}MEW manual downloads were not completed. Skipping MEW automation.{COLOR_RESET}")
else:
progress_callback, close_progress = create_vnv_cli_progress_callback(print)
try:
_mew_appid = context.get('appid') or getattr(self.modlist_handler, 'appid', None)
if not _mew_appid:
_mew_appid = self.shortcut_handler.get_appid_for_shortcut(
modlist_name, str(modlist_path / "ModOrganizer.exe")
)
automation_ran, error = run_mew_automation_if_applicable(
modlist_name=modlist_name,
modlist_install_location=modlist_path,
game_root=None, # Will be auto-detected
appid=_mew_appid,
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=progress_callback,
manual_file_callback=build_vnv_cli_manual_file_callback(mew_service.fnv_tools, output_callback=print),
confirmation_callback=lambda _description: True,
)
finally:
close_progress()
if automation_ran and not error:
print(f"{COLOR_INFO}MEW post-install automation completed.{COLOR_RESET}")
if error:
print(f"{COLOR_WARNING}MEW automation encountered an error: {error}{COLOR_RESET}")
print(f"{COLOR_INFO}You can complete these steps manually by following: https://mojaveexpressguide.com/docs/Installation{COLOR_RESET}")
else:
print(f"{COLOR_INFO}MEW automation skipped by user.{COLOR_RESET}")
except Exception as e:
self.logger.debug(f"MEW automation check skipped: {e}")
# Not an error - just means MEW automation wasn't applicable
if not gui_mode:
try:
from jackify.backend.handlers.modlist_install_cli_ttw import prompt_ttw_if_eligible
@@ -0,0 +1,216 @@
"""
MEW Integration Helper
Helper functions to integrate Mojave Express post-install automation into modlist workflows.
Mirrors vnv_integration_helper.py. Handles detection, confirmation, and execution for:
- Install Modlist
- Configure New Modlist
- Configure Existing Modlist
"""
import logging
import configparser
import re
from pathlib import Path
from typing import Optional, Callable, Tuple
from .mew_post_install_service import MEWPostInstallService
logger = logging.getLogger(__name__)
def _parse_bytearray_value(value: str) -> str:
"""Parse Qt @ByteArray format, e.g. '@ByteArray(Mojave Express)' -> 'Mojave Express'."""
match = re.match(r'@ByteArray\((.*)\)', value)
if match:
return match.group(1)
return value
def _check_modorganizer_ini_profile(modlist_install_location: Path) -> bool:
"""Check ModOrganizer.ini for a MEW profile name."""
try:
mo_ini_path = modlist_install_location / "ModOrganizer.ini"
if not mo_ini_path.exists():
logger.debug(f"ModOrganizer.ini not found at {mo_ini_path}")
return False
config = configparser.ConfigParser()
config.read(mo_ini_path, encoding='utf-8-sig')
if 'General' not in config:
logger.debug("No [General] section in ModOrganizer.ini")
return False
selected_profile_raw = config.get('General', 'selected_profile', fallback='')
if not selected_profile_raw:
logger.debug("No selected_profile in ModOrganizer.ini")
return False
selected_profile = _parse_bytearray_value(selected_profile_raw).strip().lower()
logger.debug(f"Found selected_profile: {selected_profile}")
return selected_profile in ("mojave express", "mew")
except Exception as e:
logger.debug(f"Error checking ModOrganizer.ini for MEW profile: {e}")
return False
def should_offer_mew_automation(modlist_name: str, modlist_install_location: Optional[Path] = None) -> bool:
"""
Check if MEW automation should be offered for this modlist.
Detection methods (in order of reliability):
1. Check ModOrganizer.ini selected_profile (most reliable)
2. Check modlist name for MEW patterns
"""
if modlist_install_location:
if _check_modorganizer_ini_profile(modlist_install_location):
logger.info(f"MEW detected via ModOrganizer.ini profile in {modlist_install_location}")
return True
modlist_name_lower = modlist_name.lower()
if "mojave express" in modlist_name_lower or modlist_name_lower == "mew":
logger.info(f"MEW detected via name pattern in '{modlist_name}'")
return True
return False
def _find_wine_binary() -> Optional[str]:
"""
Locate a wine binary from the configured Proton install.
Same lookup ModlistWineOpsMixin._find_wine_binary_for_registry() uses; duplicated here
(rather than instantiating that mixin's host class) since it has no other dependency.
"""
try:
from ..handlers.config_handler import ConfigHandler
proton_path = ConfigHandler().get_proton_path()
if proton_path:
proton_path = Path(proton_path).expanduser()
for candidate in (proton_path / "files" / "bin" / "wine", proton_path / "dist" / "bin" / "wine"):
if candidate.is_file():
return str(candidate)
from ..handlers.wine_utils import WineUtils
best_proton = WineUtils.select_best_proton()
if best_proton:
return WineUtils.find_proton_binary(best_proton['name'])
except Exception as e:
logger.debug(f"Error finding Wine binary for MEW automation: {e}")
return None
def run_mew_automation_if_applicable(
modlist_name: str,
modlist_install_location: Path,
game_root: Optional[Path],
appid: Optional[str] = None,
ttw_installer_path: Optional[Path] = None,
progress_callback: Optional[Callable[[str], None]] = None,
manual_file_callback: Optional[Callable[[str, str], Optional[Path]]] = None,
confirmation_callback: Optional[Callable[[str], bool]] = None
) -> Tuple[bool, Optional[str]]:
"""
Check if MEW automation should run, get user confirmation, and execute if confirmed.
Args:
modlist_name: Name of the installed modlist
modlist_install_location: Path to modlist installation
game_root: Path to game root directory
appid: Steam AppID for the modlist's shortcut, used to resolve the Wine prefix for
the Radio Fix step. Radio Fix is skipped (non-fatal) if not provided.
ttw_installer_path: Optional path to TTW_Linux_Installer (for BSA decompression)
progress_callback: Optional callback for progress updates
manual_file_callback: Optional callback for manual file selection (non-Premium)
confirmation_callback: Optional callback for user confirmation
Returns:
Tuple of (automation_was_run: bool, error_message: Optional[str])
"""
try:
if not should_offer_mew_automation(modlist_name, modlist_install_location):
logger.debug(f"Modlist '{modlist_name}' does not require MEW automation")
return False, None
logger.info(f"MEW detected: {modlist_name}")
resolved_game_root = game_root
if resolved_game_root is None:
try:
from jackify.backend.handlers.path_handler import PathHandler
game_paths = PathHandler().find_vanilla_game_paths()
resolved_game_root = game_paths.get('Fallout New Vegas')
except Exception as detect_err:
logger.debug(f"MEW game root auto-detection failed: {detect_err}")
if resolved_game_root is None:
logger.warning("MEW detected but Fallout New Vegas game root could not be resolved")
if progress_callback:
progress_callback("MEW automation skipped: Fallout New Vegas path not found")
return False, None
wineprefix = None
wine_binary = None
if appid:
try:
from ..handlers.protontricks_handler import ProtontricksHandler
wineprefix = ProtontricksHandler(steamdeck=False).get_wine_prefix_path(appid)
except Exception as e:
logger.debug(f"MEW wineprefix lookup failed: {e}")
wine_binary = _find_wine_binary()
if not wineprefix or not wine_binary:
logger.warning("MEW Radio Fix will be skipped: wine prefix/binary unavailable")
else:
logger.warning("MEW Radio Fix will be skipped: no appid provided")
mew_service = MEWPostInstallService(
modlist_install_location=modlist_install_location,
game_root=resolved_game_root,
ttw_installer_path=ttw_installer_path,
wineprefix=wineprefix,
wine_binary=wine_binary,
)
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")
if progress_callback:
progress_callback("MEW post-install steps already completed")
return False, None
if not confirmation_callback:
logger.error("MEW automation requires confirmation_callback")
return False, "MEW automation requires user confirmation"
description = mew_service.get_automation_description()
if not confirmation_callback(description):
logger.info("User declined MEW automation")
if progress_callback:
progress_callback("MEW automation skipped by user")
return False, None
logger.info("Starting MEW post-install automation")
if progress_callback:
progress_callback("Running MEW post-install automation...")
success, message = mew_service.run_all_steps(
progress_callback=progress_callback,
manual_file_callback=manual_file_callback
)
if success:
logger.info(f"MEW automation completed: {message}")
if progress_callback:
progress_callback(f"MEW automation: {message}")
return True, None
else:
logger.error(f"MEW automation failed: {message}")
return True, message
except Exception as e:
error_msg = f"MEW automation error: {str(e)}"
logger.error(error_msg, exc_info=True)
return True, error_msg
@@ -0,0 +1,248 @@
"""
Mojave Express (MEW) Post-Install Service
Automates the post-installation steps documented at:
https://mojaveexpressguide.com/docs/Installation
1. Root Mods - copy files from the GOG/Steam manual-install folder to game root
2. 4GB Patcher / BSA Decompression - these are FNV-wide fixes, not MEW-specific; the same
native Linux tools VNVPostInstallService already downloads and runs are reused here via
composition rather than duplicated.
3. Radio Fix - runs the modlist's bundled Windows batch script under Wine. Best-effort: MEW's
guide does not document a consequence of skipping this step (only that new radio songs
won't play), so a failure here is reported but never blocks the rest of automation.
"""
import logging
import shutil
import subprocess
from pathlib import Path
from typing import Optional, Callable
from .vnv_post_install_service import VNVPostInstallService
from ..handlers.subprocess_utils import get_clean_subprocess_env
logger = logging.getLogger(__name__)
class MEWPostInstallService:
"""Handles automated post-installation tasks for the Mojave Express modlist."""
ROOT_MODS_DIR_NAME = "__GOG or STEAM ONLY Files Requiring Manual Install"
RADIO_FIX_DIR_NAME = "__Radio Fix"
RADIO_FIX_BAT_NAME = "Run This.bat"
RADIO_FIX_SUCCESS_MARKER = "conversion complete"
def __init__(self, modlist_install_location: Path, game_root: Path,
ttw_installer_path: Optional[Path] = None,
wineprefix: Optional[str] = None,
wine_binary: Optional[str] = None):
"""
Args:
modlist_install_location: Path to the MEW installation
game_root: Path to Fallout New Vegas game root
ttw_installer_path: Path to TTW_Linux_Installer (used for BSA decompression)
wineprefix: WINEPREFIX for the modlist's Steam prefix (required for Radio Fix only)
wine_binary: Path to the wine binary to use (required for Radio Fix only)
"""
self.modlist_install = modlist_install_location
self.game_root = game_root
self.wineprefix = wineprefix
self.wine_binary = wine_binary
self.root_mods_dir = self.modlist_install / self.ROOT_MODS_DIR_NAME
self.radio_fix_dir = self.modlist_install / self.RADIO_FIX_DIR_NAME
# 4GB patch and BSA decompression are FNV-wide fixes with no VNV-specific logic in
# their implementation; reuse VNVPostInstallService for these two steps (including
# its shared download cache) instead of duplicating them.
self.fnv_tools = VNVPostInstallService(
modlist_install_location=modlist_install_location,
game_root=game_root,
ttw_installer_path=ttw_installer_path,
)
def should_run_automation(self, modlist_name: str) -> bool:
"""Check if this modlist should trigger MEW automation."""
name = modlist_name.lower()
return "mojave express" in name or name == "mew"
def get_automation_description(self) -> str:
return (
"Mojave Express Automation\n\n"
"Jackify can automatically perform the following post-install steps:\n\n"
"1. Copy root mods to game directory\n"
"2. Download and run Linux 4GB patcher\n"
"3. Download and run BSA decompressor (reduces loading times)\n"
"4. Run the Radio Fix (adds new radio songs to in-game radios)\n\n"
"Jackify will download the required tools automatically where possible.\n"
"If you are not a Nexus Premium member, you will be prompted to\n"
"manually download any tools that cannot be fetched automatically.\n\n"
"Would you like Jackify to automate these steps?"
)
def get_manual_download_items(self, include_bsa: bool = False) -> list:
return self.fnv_tools.get_manual_download_items(include_bsa=include_bsa)
def check_already_completed(self) -> dict:
"""
Check which MEW automation steps have already been completed.
Returns:
Dict with keys: 'root_mods', '4gb_patch', 'bsa_decompressed', 'radio_fix'
"""
fnv_status = self.fnv_tools.check_already_completed()
radio_marker = self.game_root / ".jackify_mew_radio_fixed"
return {
'root_mods': (self.game_root / "FNVpatch.exe").exists(),
'4gb_patch': fnv_status['4gb_patch'],
'bsa_decompressed': fnv_status['bsa_decompressed'],
'radio_fix': radio_marker.exists(),
}
def copy_root_mods(self) -> tuple[bool, str]:
"""
Copy files from the GOG/Steam manual-install folder to game root.
Returns:
(success: bool, message: str)
"""
try:
if not self.root_mods_dir.exists():
return False, (
f"Manual install directory not found: {self.root_mods_dir}. "
"Epic Games installs are not currently supported by this automation."
)
if not self.game_root.exists():
return False, f"Game root directory not found: {self.game_root}"
copied_files = []
for item in self.root_mods_dir.iterdir():
dest = self.game_root / item.name
if item.is_file():
shutil.copy2(item, dest)
copied_files.append(item.name)
elif item.is_dir():
shutil.copytree(item, dest, dirs_exist_ok=True)
copied_files.append(f"{item.name}/")
if not copied_files:
return False, "No files found to copy"
logger.info(f"Copied {len(copied_files)} items to game root")
return True, f"Copied {len(copied_files)} items to game root"
except Exception as e:
error_msg = f"Failed to copy root mods: {str(e)}"
logger.error(error_msg, exc_info=True)
return False, error_msg
def run_4gb_patcher(self, progress_callback: Optional[Callable[[str], None]] = None,
manual_file_callback: Optional[Callable[[str, str], Optional[Path]]] = None) -> tuple[bool, str]:
return self.fnv_tools.run_4gb_patcher(progress_callback, manual_file_callback)
def run_bsa_decompressor(self, progress_callback: Optional[Callable[[str], None]] = None,
manual_file_callback: Optional[Callable[[str, str], Optional[Path]]] = None) -> tuple[bool, str]:
return self.fnv_tools.run_bsa_decompressor(progress_callback, manual_file_callback)
def run_radio_fix(self, progress_callback: Optional[Callable[[str], None]] = None) -> tuple[bool, str]:
"""
Run the bundled Radio Fix batch script under Wine.
Non-critical step - see class docstring.
Returns:
(success: bool, message: str)
"""
marker = self.game_root / ".jackify_mew_radio_fixed"
if marker.exists():
return True, "Radio Fix already completed"
bat_path = self.radio_fix_dir / self.RADIO_FIX_BAT_NAME
if not bat_path.exists():
return False, f"Radio Fix script not found: {bat_path}"
if not self.wine_binary or not self.wineprefix:
return False, "Radio Fix requires a Wine prefix, but none was available"
if progress_callback:
progress_callback("Running Radio Fix...")
env = get_clean_subprocess_env()
env['WINEPREFIX'] = self.wineprefix
env['WINEDEBUG'] = '-all'
try:
result = subprocess.run(
[self.wine_binary, "cmd", "/c", self.RADIO_FIX_BAT_NAME],
cwd=str(self.radio_fix_dir),
env=env,
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
timeout=1800,
)
except subprocess.TimeoutExpired:
return False, "Radio Fix timed out after 30 minutes"
except Exception as e:
error_msg = f"Failed to run Radio Fix: {str(e)}"
logger.error(error_msg, exc_info=True)
return False, error_msg
output = (result.stdout or "") + (result.stderr or "")
if self.RADIO_FIX_SUCCESS_MARKER in output.lower():
marker.touch()
logger.info("Radio Fix completed successfully")
return True, "Radio Fix completed successfully"
logger.warning(f"Radio Fix did not report success. Output:\n{output[-2000:]}")
return False, "Radio Fix ran but did not report success - it may need to be run manually"
def run_all_steps(self, progress_callback: Optional[Callable[[str], None]] = None,
manual_file_callback: Optional[Callable[[str, str], Optional[Path]]] = None) -> tuple[bool, str]:
"""
Run all MEW post-install steps in sequence.
Radio Fix failure is logged and reported but does not fail the overall result.
Returns:
(success: bool, message: str)
"""
def update_progress(msg: str):
if progress_callback:
progress_callback(msg)
logger.info(msg)
try:
update_progress("Step 1/4: Copying root mods to game directory...")
success, msg = self.copy_root_mods()
if not success:
return False, f"Root mods failed: {msg}"
update_progress(f"Root mods: {msg}")
update_progress("Step 2/4: Downloading and running 4GB patcher...")
success, msg = self.run_4gb_patcher(update_progress, manual_file_callback)
if not success:
return False, f"4GB patcher failed: {msg}"
update_progress(f"4GB patcher: {msg}")
update_progress("Step 3/4: Downloading and running BSA decompressor...")
success, msg = self.run_bsa_decompressor(update_progress, manual_file_callback)
if not success:
return False, f"BSA decompression failed: {msg}"
update_progress(f"BSA decompression: {msg}")
update_progress("Step 4/4: Running Radio Fix...")
success, msg = self.run_radio_fix(update_progress)
if success:
update_progress(f"Radio Fix: {msg}")
else:
update_progress(f"Radio Fix skipped: {msg}")
logger.warning(f"Radio Fix non-fatal failure: {msg}")
return True, "MEW post-install completed"
except Exception as e:
error_msg = f"MEW post-install failed: {str(e)}"
logger.error(error_msg, exc_info=True)
return False, error_msg
@@ -113,7 +113,7 @@ class NativeSteamOperationsService:
compatdata_paths = self._find_compatdata_paths()
for compatdata_base in compatdata_paths:
prefix_path = compatdata_base / appid / "pfx"
prefix_path = compatdata_base / str(appid) / "pfx"
logger.debug(f"Checking prefix path: {prefix_path}")
if prefix_path.exists():
@@ -13,11 +13,9 @@ Uses native Linux tools (no Wine required) by downloading from Nexus with OAuth.
import logging
import os
import json
import shutil
import subprocess
import stat
import tempfile
import zipfile
from pathlib import Path
from typing import Optional, Callable
@@ -521,6 +519,15 @@ class VNVPostInstallService:
"""
Download FNV BSA Decompressor MPI and run via TTW_Linux_Installer.
TTW_Linux_Installer v0.2.0 (the pinned build since Jackify v0.7.2) is a Rust rewrite
with a plain `install --mpi ... --fnv ... --dest ...` CLI; the old v0.0.7 C# build's
`--start` flag and PascalCase ttw-config.json are gone. `--dest` is passed the game
root directly (not a scratch dir) because the installer resolves manifest asset
locations relative to it and writes final output there itself - see
AssetProcessor::new(..., request.destination_path.clone(), ...) in that project's
install_runner.rs. Any scratch subdirectory it creates under `--dest` is cleaned up
by the installer itself before it exits.
Args:
progress_callback: Optional callback for progress updates
manual_file_callback: Optional callback for manual file selection
@@ -590,34 +597,18 @@ class VNVPostInstallService:
return False, f"Failed to prepare BSA Decompressor package: {msg}"
logger.info(msg)
# Create temp output directory
with tempfile.TemporaryDirectory() as temp_output:
temp_output_path = Path(temp_output)
# Create config file for TTW_Linux_Installer (handles spaces in paths better)
config_file = self.ttw_installer_path.parent / "ttw-config.json"
config_data = {
"FalloutNVRoot": str(self.game_root),
"MpiPackagePath": str(mpi_path),
"DestinationPath": str(temp_output_path)
}
with open(config_file, 'w') as f:
json.dump(config_data, f, indent=2)
logger.debug(f"Created MPI config file: {config_file}")
# Run via TTW_Linux_Installer
if progress_callback:
progress_callback("Ensuring TTW_Linux_Installer is available...")
progress_callback("Running BSA decompressor...")
cmd = [
str(self.ttw_installer_path),
"--start"
"install",
"--mpi", str(mpi_path),
"--fnv", str(self.game_root),
"--dest", str(self.game_root),
]
logger.info(f"Running BSA decompressor: {' '.join(cmd)}")
logger.debug(f"Using config file: {config_file}")
logger.debug(f"Config: {json.dumps(config_data, indent=2)}")
env = get_clean_subprocess_env()
@@ -633,127 +624,53 @@ class VNVPostInstallService:
bufsize=1
)
# Pattern to match progress: "Assets processed: 12345/48649"
progress_pattern = re.compile(r'Assets processed: (\d+)/(\d+)')
last_progress = None
# Rust installer logs "Progress: NN% - message" lines (see cli.rs run_install)
progress_pattern = re.compile(r'Progress:\s*(\d+)%\s*-\s*(.*)')
last_percent = None
# Capture all output for diagnostics
all_output = []
already_modified_detected = False
installation_complete = False
# Stream output line by line
for line in process.stdout:
line = line.rstrip()
all_output.append(line)
# Check for "already modified" messages
if "already" in line.lower() and ("modified" in line.lower() or "decompressed" in line.lower()):
already_modified_detected = True
logger.info(f"BSA decompressor reports: {line}")
if "=== Installation Complete ===" in line:
installation_complete = True
# Check for progress updates
match = progress_pattern.search(line)
if match:
current = int(match.group(1))
total = int(match.group(2))
percent = (current / total * 100) if total > 0 else 0
progress_msg = f"Decompressing BSA files: {current}/{total} ({percent:.1f}%)"
# Only send update if progress changed significantly
if last_progress is None or current - last_progress >= total // 100:
percent = int(match.group(1))
detail = match.group(2)
if last_percent is None or percent != last_percent:
if progress_callback:
progress_callback(progress_msg)
# Log progress updates (not every single file)
logger.debug(f"BSA decompression progress: {current}/{total} ({percent:.1f}%)")
last_progress = current
progress_callback(f"Decompressing BSA files: {percent}% - {detail}")
last_percent = percent
# Wait for process to complete
return_code = process.wait(timeout=600)
# Log full output for debugging failures
if return_code != 0:
logger.debug(f"BSA decompressor output:\n" + "\n".join(all_output[-50:])) # Last 50 lines
logger.debug("BSA decompressor output:\n" + "\n".join(all_output[-50:]))
# Clean up config file after execution
try:
if config_file.exists():
config_file.unlink()
logger.debug(f"Cleaned up config file: {config_file}")
except Exception as e:
logger.warning(f"Failed to clean up config file: {e}")
if return_code == 0:
# Check if files were actually extracted to temp directory
extracted_files = list(temp_output_path.rglob("*"))
if extracted_files:
logger.info(f"BSA decompression extracted {len(extracted_files)} files")
# Copy extracted files back to game Data directory
data_dir = self.game_root / "Data"
copied_count = 0
for extracted_file in extracted_files:
if extracted_file.is_file():
# Preserve relative path structure
relative_path = extracted_file.relative_to(temp_output_path)
dest_file = data_dir / relative_path
dest_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(extracted_file, dest_file)
copied_count += 1
logger.info(f"Copied {copied_count} decompressed files to {data_dir}")
# Create marker file to indicate completion
if return_code == 0 and installation_complete:
marker_file = self.game_root / ".jackify_bsa_decompressed"
marker_file.touch()
logger.info("BSA decompression completed successfully")
return True, "BSA decompression completed successfully"
else:
# No files extracted - might be already decompressed or failed silently
logger.warning("BSA decompressor returned 0 but no files were extracted")
# Check if already decompressed by looking for marker
marker_file = self.game_root / ".jackify_bsa_decompressed"
if marker_file.exists():
logger.info("BSA files already decompressed (marker file exists)")
return True, "BSA files already decompressed"
else:
return False, "BSA decompressor completed but no files were extracted"
else:
# Exit code 1 often means "already decompressed" - check output and marker
marker_file = self.game_root / ".jackify_bsa_decompressed"
# If output explicitly said "already modified/decompressed", treat as success
if already_modified_detected:
logger.info("BSA decompressor reports files already modified - marking as completed")
marker_file.touch()
return True, "BSA files already decompressed"
# Check marker file
if marker_file.exists():
logger.info("BSA decompressor returned error but marker file exists - assuming already completed")
logger.info("BSA decompressor returned an error but marker file exists - assuming already completed")
return True, "BSA decompression already completed"
# Try to provide helpful error message based on exit code and output
logger.error(f"BSA decompressor failed with exit code {return_code}")
error_details = f"BSA decompressor failed with exit code {return_code}."
if return_code == 1:
error_details += (
"\n\nThis may indicate the BSA files are already decompressed or modified. "
"If you've run this before, the step may have already completed. "
"Otherwise, try running the decompressor manually from: "
"https://www.nexusmods.com/newvegas/mods/65854"
)
else:
error_details += (
f"\n\nPlease check that:\n"
error_details = (
f"BSA decompressor failed with exit code {return_code}.\n\n"
f"Please check that:\n"
f"1. Fallout New Vegas is properly installed at: {self.game_root}\n"
f"2. The BSA files exist in the Data directory\n"
f"3. You have write permissions to the game directory\n\n"
f"You can complete this step manually using the guide at:\n"
f"https://vivanewvegas.moddinglinked.com/wabbajack.html"
f"3. You have write permissions to the game directory"
)
return False, error_details
except subprocess.TimeoutExpired:
@@ -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