diff --git a/CHANGELOG.md b/CHANGELOG.md index a90d856..8e94eb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/jackify/__init__.py b/jackify/__init__.py index eb61792..a339979 100644 --- a/jackify/__init__.py +++ b/jackify/__init__.py @@ -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" diff --git a/jackify/backend/core/modlist_operations_configuration_cli.py b/jackify/backend/core/modlist_operations_configuration_cli.py index 9ff2514..d78413d 100644 --- a/jackify/backend/core/modlist_operations_configuration_cli.py +++ b/jackify/backend/core/modlist_operations_configuration_cli.py @@ -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 diff --git a/jackify/backend/data/modlist_proton_requirements.py b/jackify/backend/data/modlist_proton_requirements.py index fded86d..de56e85 100644 --- a/jackify/backend/data/modlist_proton_requirements.py +++ b/jackify/backend/data/modlist_proton_requirements.py @@ -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()) diff --git a/jackify/backend/handlers/menu_handler_modlist.py b/jackify/backend/handlers/menu_handler_modlist.py index a931a8a..008553f 100644 --- a/jackify/backend/handlers/menu_handler_modlist.py +++ b/jackify/backend/handlers/menu_handler_modlist.py @@ -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 diff --git a/jackify/backend/services/mew_integration_helper.py b/jackify/backend/services/mew_integration_helper.py new file mode 100644 index 0000000..2250562 --- /dev/null +++ b/jackify/backend/services/mew_integration_helper.py @@ -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 diff --git a/jackify/backend/services/mew_post_install_service.py b/jackify/backend/services/mew_post_install_service.py new file mode 100644 index 0000000..5ab642f --- /dev/null +++ b/jackify/backend/services/mew_post_install_service.py @@ -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 diff --git a/jackify/backend/services/native_steam_operations_service.py b/jackify/backend/services/native_steam_operations_service.py index 78af8bc..b76d873 100644 --- a/jackify/backend/services/native_steam_operations_service.py +++ b/jackify/backend/services/native_steam_operations_service.py @@ -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(): diff --git a/jackify/backend/services/vnv_post_install_service.py b/jackify/backend/services/vnv_post_install_service.py index 53f7add..8b204f8 100644 --- a/jackify/backend/services/vnv_post_install_service.py +++ b/jackify/backend/services/vnv_post_install_service.py @@ -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,171 +597,81 @@ 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) + if progress_callback: + progress_callback("Running BSA decompressor...") - # 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}") + cmd = [ + str(self.ttw_installer_path), + "install", + "--mpi", str(mpi_path), + "--fnv", str(self.game_root), + "--dest", str(self.game_root), + ] - # Run via TTW_Linux_Installer - if progress_callback: - progress_callback("Ensuring TTW_Linux_Installer is available...") - progress_callback("Running BSA decompressor...") + logger.info(f"Running BSA decompressor: {' '.join(cmd)}") - cmd = [ - str(self.ttw_installer_path), - "--start" - ] + env = get_clean_subprocess_env() - 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)}") + # Stream output and parse progress + import re + process = subprocess.Popen( + cmd, + cwd=str(self.ttw_installer_path.parent), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 + ) - env = get_clean_subprocess_env() + # 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 - # Stream output and parse progress - import re - process = subprocess.Popen( - cmd, - cwd=str(self.ttw_installer_path.parent), - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1 - ) + all_output = [] + installation_complete = False - # Pattern to match progress: "Assets processed: 12345/48649" - progress_pattern = re.compile(r'Assets processed: (\d+)/(\d+)') - last_progress = None + for line in process.stdout: + line = line.rstrip() + all_output.append(line) - # Capture all output for diagnostics - all_output = [] - already_modified_detected = False + if "=== Installation Complete ===" in line: + installation_complete = True - # Stream output line by line - for line in process.stdout: - line = line.rstrip() - all_output.append(line) + match = progress_pattern.search(line) + if match: + percent = int(match.group(1)) + detail = match.group(2) + if last_percent is None or percent != last_percent: + if progress_callback: + progress_callback(f"Decompressing BSA files: {percent}% - {detail}") + last_percent = percent - # 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}") + return_code = process.wait(timeout=600) - # 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}%)" + if return_code != 0: + logger.debug("BSA decompressor output:\n" + "\n".join(all_output[-50:])) - # Only send update if progress changed significantly - if last_progress is None or current - last_progress >= total // 100: - 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 + 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" - # Wait for process to complete - return_code = process.wait(timeout=600) + marker_file = self.game_root / ".jackify_bsa_decompressed" + if marker_file.exists(): + logger.info("BSA decompressor returned an error but marker file exists - assuming already completed") + return True, "BSA decompression already completed" - # 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 - - # 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 - 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") - 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" - 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" - ) - - return False, error_details + logger.error(f"BSA decompressor failed with exit code {return_code}") + 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" + ) + return False, error_details except subprocess.TimeoutExpired: return False, "BSA decompression timed out after 10 minutes" diff --git a/jackify/frontends/cli/commands/configure_modlist.py b/jackify/frontends/cli/commands/configure_modlist.py index d4d109c..47b5820 100644 --- a/jackify/frontends/cli/commands/configure_modlist.py +++ b/jackify/frontends/cli/commands/configure_modlist.py @@ -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 ( diff --git a/jackify/frontends/gui/dialogs/game_proton_warning_dialog.py b/jackify/frontends/gui/dialogs/game_proton_warning_dialog.py new file mode 100644 index 0000000..bbb3bcd --- /dev/null +++ b/jackify/frontends/gui/dialogs/game_proton_warning_dialog.py @@ -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 = "
".join(f"- {v}" for v in recommended) + details_text = ( + "
" + "
(In order of recommendation)
" + f"
{recommended_html}
" + "
" + ) + 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}") diff --git a/jackify/frontends/gui/screens/configure_existing_modlist.py b/jackify/frontends/gui/screens/configure_existing_modlist.py index 34aa649..3c60437 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist.py @@ -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', diff --git a/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py b/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py index 4b90acc..33844ae 100644 --- a/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py +++ b/jackify/frontends/gui/screens/configure_existing_modlist_workflow.py @@ -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 = ( diff --git a/jackify/frontends/gui/screens/configure_new_modlist.py b/jackify/frontends/gui/screens/configure_new_modlist.py index 6bf0807..0310692 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist.py +++ b/jackify/frontends/gui/screens/configure_new_modlist.py @@ -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', diff --git a/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py b/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py index 47c5e9e..c86934f 100644 --- a/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py +++ b/jackify/frontends/gui/screens/configure_new_modlist_dialogs.py @@ -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") diff --git a/jackify/frontends/gui/screens/install_modlist.py b/jackify/frontends/gui/screens/install_modlist.py index b8eb221..54529f6 100644 --- a/jackify/frontends/gui/screens/install_modlist.py +++ b/jackify/frontends/gui/screens/install_modlist.py @@ -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)""" diff --git a/jackify/frontends/gui/screens/install_modlist_configuration.py b/jackify/frontends/gui/screens/install_modlist_configuration.py index 65969d7..3a98829 100644 --- a/jackify/frontends/gui/screens/install_modlist_configuration.py +++ b/jackify/frontends/gui/screens/install_modlist_configuration.py @@ -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() diff --git a/jackify/frontends/gui/screens/install_modlist_mew.py b/jackify/frontends/gui/screens/install_modlist_mew.py new file mode 100644 index 0000000..13ac4bc --- /dev/null +++ b/jackify/frontends/gui/screens/install_modlist_mew.py @@ -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), + }, + ) diff --git a/jackify/frontends/gui/screens/install_modlist_postinstall.py b/jackify/frontends/gui/screens/install_modlist_postinstall.py index 365c6ed..4f7684c 100644 --- a/jackify/frontends/gui/screens/install_modlist_postinstall.py +++ b/jackify/frontends/gui/screens/install_modlist_postinstall.py @@ -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: diff --git a/jackify/frontends/gui/screens/install_verifier_mixin.py b/jackify/frontends/gui/screens/install_verifier_mixin.py index b183129..d1705ad 100644 --- a/jackify/frontends/gui/screens/install_verifier_mixin.py +++ b/jackify/frontends/gui/screens/install_verifier_mixin.py @@ -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) diff --git a/jackify/frontends/gui/services/mew_automation_controller.py b/jackify/frontends/gui/services/mew_automation_controller.py new file mode 100644 index 0000000..2794bf6 --- /dev/null +++ b/jackify/frontends/gui/services/mew_automation_controller.py @@ -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