mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 01:23:46 +02:00
Release v0.7.2.1 - Mojave Express Support, BSA Decompressor Fix
This commit is contained in:
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user