""" modlist_install_cli.py Discovery phase for Jackify's modlist install CLI feature. """ import os from pathlib import Path from typing import Optional, Dict, List, Any, Union from .protontricks_handler import ProtontricksHandler from .shortcut_handler import ShortcutHandler from .menu_handler import MenuHandler, ModlistMenuHandler from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_SUCCESS, COLOR_WARNING, COLOR_SELECTION # Standard logging (no file handler) - LoggingHandler import removed import re import subprocess import logging import sys import json import shlex import time import pty # from src.core.compressonator import run_compressonatorcli # TODO: Implement compressonator integration # Import UI Colors first - these should always be available from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR, COLOR_SELECTION, COLOR_WARNING # Standard logging (no file handler) - LoggingHandler import removed # Attempt to import readline for tab completion READLINE_AVAILABLE = False try: import readline READLINE_AVAILABLE = True # Check if running in a non-interactive environment (e.g., some CI) if 'libedit' in readline.__doc__: # libedit doesn't support set_completion_display_matches_hook pass # Add other potential checks if needed except ImportError: # readline not available on Windows or potentially minimal environments pass except Exception as e: # Catch other potential errors during readline import/setup logging.warning(f"Readline import failed: {e}") # Use standard logging before our handler pass # Initialize logger for the module logger = logging.getLogger(__name__) # Standard logger init # Helper function to get path to jackify-install-engine def get_jackify_engine_path(): if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): # Running in a PyInstaller bundle # Engine is expected at /jackify/engine/jackify-engine return os.path.join(sys._MEIPASS, 'jackify', 'engine', 'jackify-engine') else: # Running in a normal Python environment from source # Current file is in src/jackify/backend/handlers/modlist_install_cli.py # Engine is at src/jackify/engine/jackify-engine current_file_dir = os.path.dirname(os.path.abspath(__file__)) # Navigate up from src/jackify/backend/handlers/ to src/jackify/ jackify_dir = os.path.dirname(os.path.dirname(current_file_dir)) return os.path.join(jackify_dir, 'engine', 'jackify-engine') class ModlistInstallCLI: """ Handles the discovery phase for installing a Wabbajack modlist via CLI. """ def __init__(self, menu_handler: MenuHandler, steamdeck: bool = False): self.menu_handler = menu_handler self.steamdeck = steamdeck self.protontricks_handler = ProtontricksHandler(steamdeck) self.shortcut_handler = ShortcutHandler(steamdeck=steamdeck) self.context = {} # Use standard logging (no file handler) self.logger = logging.getLogger(__name__) self.logger.propagate = False # Prevent duplicate logs if root logger is also configured def run_discovery_phase(self, context_override=None) -> Optional[Dict]: """ Run the discovery phase: prompt for all required info, and validate inputs. Returns a context dict with all collected info, or None if cancelled. Accepts context_override for pre-filled values (e.g., for Tuxborn/machineid flow). """ self.logger.info("Starting modlist discovery phase (restored logic).") print(f"\n{COLOR_PROMPT}--- Wabbajack Modlist Install: Discovery Phase ---{COLOR_RESET}") if context_override: self.context.update(context_override) if 'resolution' in context_override: self.context['resolution'] = context_override['resolution'] else: self.context = {} is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' # Only require game_type for non-Tuxborn workflows if self.context.get('machineid'): required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key'] else: required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key', 'game_type'] has_modlist = self.context.get('modlist_value') or self.context.get('machineid') missing = [k for k in required_keys if not self.context.get(k)] if is_gui_mode: if missing or not has_modlist: print(f"ERROR: Missing required arguments for GUI workflow: {', '.join(missing)}") if not has_modlist: print("ERROR: Missing modlist_value or machineid for GUI workflow.") print("This workflow must be fully non-interactive. Please report this as a bug if you see this message.") return None self.logger.info("All required context present in GUI mode, skipping prompts.") return self.context # Get engine path using the helper engine_executable = get_jackify_engine_path() self.logger.debug(f"Engine executable path: {engine_executable}") if not os.path.exists(engine_executable): self.logger.error(f"jackify-install-engine not found at {engine_executable}") print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_RESET}") print(f"{COLOR_INFO}Expected: {engine_executable}{COLOR_RESET}") return None engine_dir = os.path.dirname(engine_executable) # 1. Prompt for modlist source (unless using machineid from context_override) if 'machineid' not in self.context: print("\n" + "-" * 28) # Separator print(f"{COLOR_PROMPT}How would you like to select your modlist?{COLOR_RESET}") print(f"{COLOR_SELECTION}1.{COLOR_RESET} Select from a list of available modlists") print(f"{COLOR_SELECTION}2.{COLOR_RESET} Provide the path to a .wabbajack file on disk") print(f"{COLOR_SELECTION}0.{COLOR_RESET} Cancel and return to previous menu") source_choice = input(f"{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip() self.logger.debug(f"User selected modlist source option: {source_choice}") if source_choice == '1': self.context['modlist_source_type'] = 'online_list' print(f"\n{COLOR_INFO}Fetching available modlists... This may take a moment.{COLOR_RESET}") try: env = os.environ.copy() env["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1" self.logger.info("Setting DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 for jackify-engine process.") # Use the engine path from the helper function, but the command structure from restored. engine_executable_path_for_subprocess = get_jackify_engine_path() command = [engine_executable_path_for_subprocess, 'list-modlists', '--show-all-sizes', '--show-machine-url'] self.logger.info(f"Executing command: {' '.join(command)} in CWD: {engine_dir}") # check=True as in restored logic result = subprocess.run( command, capture_output=True, text=True, check=True, env=env, cwd=engine_dir ) # self.logger.debug(f"Engine stdout (raw):\n{result.stdout}") # COMMENTED OUT - too verbose lines = result.stdout.splitlines() # Parse new format: [STATUS] Modlist Name - Game - Download|Install|Total - MachineURL # STATUS indicators: [DOWN], [NSFW], or both [DOWN] [NSFW] raw_modlists_from_engine = [] for line in lines: line = line.strip() if not line or line.startswith('Loading') or line.startswith('Loaded'): continue # Extract status indicators status_down = '[DOWN]' in line status_nsfw = '[NSFW]' in line # Remove status indicators to get clean line clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip() # Split on ' - ' to get: [Modlist Name, Game, Sizes, MachineURL] parts = clean_line.split(' - ') if len(parts) != 4: continue # Skip malformed lines modlist_name = parts[0].strip() game_name = parts[1].strip() sizes_str = parts[2].strip() machine_url = parts[3].strip() # Parse sizes: "Download|Install|Total" (e.g., "203GB|130GB|333GB") size_parts = sizes_str.split('|') if len(size_parts) != 3: continue # Skip if sizes don't match expected format download_size = size_parts[0].strip() install_size = size_parts[1].strip() total_size = size_parts[2].strip() # Skip if any required data is missing if not modlist_name or not game_name or not machine_url: continue raw_modlists_from_engine.append({ 'id': modlist_name, # Use modlist name as ID for compatibility 'name': modlist_name, 'game': game_name, 'download_size': download_size, 'install_size': install_size, 'total_size': total_size, 'machine_url': machine_url, # Store machine URL for installation 'status_down': status_down, 'status_nsfw': status_nsfw }) self.logger.info(f"Scraped {len(raw_modlists_from_engine)} modlists after revised regex and filtering logic.") if not raw_modlists_from_engine: print(f"{COLOR_WARNING}No modlists found after applying revised regex and filtering logic.{COLOR_RESET}") return None # EXACT game_type_map and grouping logic from restored file game_type_map = { '1': ('Skyrim', ['Skyrim', 'Skyrim Special Edition']), '2': ('Fallout 4', ['Fallout 4']), '3': ('Fallout New Vegas', ['Fallout New Vegas']), '4': ('Oblivion', ['Oblivion']), '5': ('Other Games', None) # Using None as in restored for keyword list } grouped_modlists = {k: [] for k in game_type_map} for m_info in raw_modlists_from_engine: # m_info is like {'id': ..., 'game': ...} found_category = False for cat_key, (cat_label, cat_keywords) in game_type_map.items(): if cat_key == '5': # Skip 'Other Games' for direct matching initially continue if cat_keywords: # Ensure there are keywords to check (handles 'Other Games' with None) for keyword in cat_keywords: if keyword.lower() in m_info['game'].lower(): grouped_modlists[cat_key].append(m_info) found_category = True break # Found category for this modlist if found_category: break # Move to next modlist if not found_category: grouped_modlists['5'].append(m_info) # Add to 'Other Games' selected_modlist_info = None # Will store {'id': ..., 'game': ...} while not selected_modlist_info: print(f"\n{COLOR_PROMPT}Select a game category:{COLOR_RESET}") category_display_map = {} # Maps displayed number to actual game_type_map key display_idx = 1 # Iterate in a defined order for consistent menu for cat_key_ordered in ['1','2','3','4','5']: if cat_key_ordered in grouped_modlists and grouped_modlists[cat_key_ordered]: # Only show if non-empty cat_label = game_type_map[cat_key_ordered][0] print(f" {COLOR_SELECTION}{display_idx}.{COLOR_RESET} {cat_label} ({len(grouped_modlists[cat_key_ordered])} modlists)") category_display_map[str(display_idx)] = cat_key_ordered display_idx += 1 if display_idx == 1: # No categories had any modlists print(f"{COLOR_WARNING}No modlists found to display after grouping. Engine output might be empty or filtered entirely.{COLOR_RESET}") return None print(f" {COLOR_SELECTION}0.{COLOR_RESET} Cancel") game_cat_choice = input(f"{COLOR_PROMPT}Enter selection: {COLOR_RESET}").strip() if game_cat_choice == '0': self.logger.info("User cancelled game category selection.") return None actual_cat_key = category_display_map.get(game_cat_choice) if not actual_cat_key: print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}") continue # modlist_group_for_game is a list of dicts like {'id': ..., 'game': ...} modlist_group_for_game = sorted(grouped_modlists[actual_cat_key], key=lambda x: x['id'].lower()) print(f"\n{COLOR_SUCCESS}Available Modlists for {game_type_map[actual_cat_key][0]}:{COLOR_RESET}") for idx, m_detail in enumerate(modlist_group_for_game, 1): if actual_cat_key == '5': # 'Other Games' category print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail['id']} ({m_detail['game']})") else: print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail['id']}") print(f" {COLOR_SELECTION}0.{COLOR_RESET} Back to game categories") while True: mod_choice_idx_str = input(f"{COLOR_PROMPT}Select modlist (or 0): {COLOR_RESET}").strip() if mod_choice_idx_str == '0': break if mod_choice_idx_str.isdigit(): mod_idx = int(mod_choice_idx_str) - 1 if 0 <= mod_idx < len(modlist_group_for_game): selected_modlist_info = modlist_group_for_game[mod_idx] self.context['modlist_source'] = 'identifier' # Use machine_url for installation, display name for suggestions self.context['modlist_value'] = selected_modlist_info.get('machine_url', selected_modlist_info['id']) self.context['modlist_game'] = selected_modlist_info['game'] self.context['modlist_name_suggestion'] = selected_modlist_info['id'].split('/')[-1] self.logger.info(f"User selected online modlist: {selected_modlist_info}") break else: print(f"{COLOR_ERROR}Invalid modlist number.{COLOR_RESET}") else: print(f"{COLOR_ERROR}Invalid input. Please enter a number.{COLOR_RESET}") if selected_modlist_info: break except subprocess.CalledProcessError as e: self.logger.error(f"list-modlists failed. Code: {e.returncode}") if e.stdout: self.logger.error(f"Engine stdout:\n{e.stdout}") if e.stderr: self.logger.error(f"Engine stderr:\n{e.stderr}") print(f"{COLOR_ERROR}Failed to fetch modlist list. Engine error (Code: {e.returncode}).{COLOR_RESET}") return None except FileNotFoundError: self.logger.error(f"Engine not found: {engine_executable_path_for_subprocess}") print(f"{COLOR_ERROR}Critical error: jackify-install-engine not found.{COLOR_RESET}") return None except Exception as e: self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True) print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_RESET}") return None elif source_choice == '2': self.context['modlist_source_type'] = 'local_file' print(f"\n{COLOR_PROMPT}Please provide the path to your .wabbajack file (tab-completion supported).{COLOR_RESET}") modlist_path = self.menu_handler.get_existing_file_path( prompt_message="Enter the path to your .wabbajack file (or 'q' to cancel):", extension_filter=".wabbajack", # Ensure this is the exact filter used by the method no_header=True # To avoid re-printing a header if get_existing_file_path has one ) if modlist_path is None: # Assumes get_existing_file_path returns None on cancel/'q' self.logger.info("User cancelled .wabbajack file selection.") print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}") return None self.context['modlist_source'] = 'path' # For install command self.context['modlist_value'] = str(modlist_path) # Suggest a name based on the file self.context['modlist_name_suggestion'] = Path(modlist_path).stem self.logger.info(f"User selected local .wabbajack file: {modlist_path}") elif source_choice == '0': self.logger.info("User cancelled modlist source selection.") print(f"{COLOR_INFO}Returning to previous menu.{COLOR_RESET}") return None else: self.logger.warning(f"Invalid modlist source choice: {source_choice}") print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}") return self.run_discovery_phase() # Re-prompt # --- Prompts for install_dir, download_dir, modlist_name, api_key --- # (This part is largely similar to the restored version, adapt as needed) # It will use self.context['modlist_name_suggestion'] if available. # 2. Prompt for modlist name (skip if 'modlist_name' already in context from override) if 'modlist_name' not in self.context or not self.context['modlist_name']: default_name = self.context.get('modlist_name_suggestion', 'MyModlist') print("\n" + "-" * 28) print(f"{COLOR_PROMPT}Enter a name for this modlist installation in Steam.{COLOR_RESET}") print(f"{COLOR_INFO}(This will be the shortcut name. Default: {default_name}){COLOR_RESET}") modlist_name_input = input(f"{COLOR_PROMPT}Modlist Name (or 'q' to cancel): {COLOR_RESET}").strip() if not modlist_name_input: # User hit enter for default modlist_name = default_name elif modlist_name_input.lower() == 'q': self.logger.info("User cancelled at modlist name prompt.") return None else: modlist_name = modlist_name_input self.context['modlist_name'] = modlist_name self.logger.debug(f"Modlist name set to: {self.context['modlist_name']}") # 3. Prompt for install directory if 'install_dir' not in self.context: # Use configurable base directory config_handler = ConfigHandler() base_install_dir = Path(config_handler.get_modlist_install_base_dir()) default_install_dir = base_install_dir / self.context['modlist_name'] print("\n" + "-" * 28) print(f"{COLOR_PROMPT}Enter the main installation directory for '{self.context['modlist_name']}'.{COLOR_RESET}") print(f"{COLOR_INFO}(Default: {default_install_dir}){COLOR_RESET}") install_dir_path = self.menu_handler.get_directory_path( prompt_message=f"{COLOR_PROMPT}Install directory (or 'q' to cancel, Enter for default): {COLOR_RESET}", default_path=default_install_dir, create_if_missing=True, no_header=True ) if install_dir_path is None: self.logger.info("User cancelled at install directory prompt.") return None self.context['install_dir'] = install_dir_path self.logger.debug(f"Install directory context set to: {self.context['install_dir']}") # 4. Prompt for download directory if 'download_dir' not in self.context: # Use configurable base directory for downloads config_handler = ConfigHandler() base_download_dir = Path(config_handler.get_modlist_downloads_base_dir()) default_download_dir = base_download_dir / self.context['modlist_name'] print("\n" + "-" * 28) print(f"{COLOR_PROMPT}Enter the downloads directory for modlist archives.{COLOR_RESET}") print(f"{COLOR_INFO}(Default: {default_download_dir}){COLOR_RESET}") download_dir_path = self.menu_handler.get_directory_path( prompt_message=f"{COLOR_PROMPT}Download directory (or 'q' to cancel, Enter for default): {COLOR_RESET}", default_path=default_download_dir, create_if_missing=True, no_header=True ) if download_dir_path is None: self.logger.info("User cancelled at download directory prompt.") return None self.context['download_dir'] = download_dir_path self.logger.debug(f"Download directory context set to: {self.context['download_dir']}") # 5. Prompt for Nexus API key (skip if in context) if 'nexus_api_key' not in self.context: from jackify.backend.services.api_key_service import APIKeyService api_key_service = APIKeyService() saved_key = api_key_service.get_saved_api_key() api_key = None if saved_key: print("\n" + "-" * 28) print(f"{COLOR_INFO}A Nexus API Key is already saved.{COLOR_RESET}") use_saved = input(f"{COLOR_PROMPT}Use the saved API key? [Y/n]: {COLOR_RESET}").strip().lower() if use_saved in ('', 'y', 'yes'): api_key = saved_key else: new_key = input(f"{COLOR_PROMPT}Enter a new Nexus API Key (or press Enter to keep the saved one): {COLOR_RESET}").strip() if new_key: api_key = new_key replace = input(f"{COLOR_PROMPT}Replace the saved key with this one? [y/N]: {COLOR_RESET}").strip().lower() if replace == 'y': if api_key_service.save_api_key(api_key): print(f"{COLOR_SUCCESS}API key saved successfully.{COLOR_RESET}") else: print(f"{COLOR_WARNING}Failed to save API key. Using for this session only.{COLOR_RESET}") else: print(f"{COLOR_INFO}Using new key for this session only. Saved key unchanged.{COLOR_RESET}") else: api_key = saved_key else: print("\n" + "-" * 28) print(f"{COLOR_INFO}A Nexus Mods API key is required for downloading mods.{COLOR_RESET}") print(f"{COLOR_INFO}You can get your personal key at: {COLOR_SELECTION}https://www.nexusmods.com/users/myaccount?tab=api{COLOR_RESET}") print(f"{COLOR_WARNING}Your API Key is NOT saved locally. It is used only for this session unless you choose to save it.{COLOR_RESET}") api_key = input(f"{COLOR_PROMPT}Enter Nexus API Key (or 'q' to cancel): {COLOR_RESET}").strip() if not api_key or api_key.lower() == 'q': self.logger.info("User cancelled or provided no API key.") return None save = input(f"{COLOR_PROMPT}Would you like to save this API key for future use? [y/N]: {COLOR_RESET}").strip().lower() if save == 'y': if api_key_service.save_api_key(api_key): print(f"{COLOR_SUCCESS}API key saved successfully.{COLOR_RESET}") else: print(f"{COLOR_WARNING}Failed to save API key. Using for this session only.{COLOR_RESET}") else: print(f"{COLOR_INFO}Using API key for this session only. It will not be saved.{COLOR_RESET}") self.context['nexus_api_key'] = api_key self.logger.debug(f"NEXUS_API_KEY is set in environment for engine (presence check).") # Display summary and confirm self._display_summary() # Ensure this method exists or implement it if self.context.get('skip_confirmation'): confirm = 'y' else: confirm = input(f"{COLOR_PROMPT}Proceed with installation using these settings? (y/N): {COLOR_RESET}").strip().lower() if confirm != 'y': self.logger.info("User cancelled at final confirmation.") print(f"{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}") return None self.logger.info("Discovery phase complete.") # Log completion first # Create a copy of the context for logging, so we don't alter the original context_for_logging = self.context.copy() if 'nexus_api_key' in context_for_logging and context_for_logging['nexus_api_key'] is not None: context_for_logging['nexus_api_key'] = "[REDACTED]" # Redact the API key for logging self.logger.info(f"Context: {context_for_logging}") # Log the redacted context return self.context def _display_summary(self): """ Display a summary of the collected context (excluding API key). """ print(f"\n{COLOR_INFO}--- Summary of Collected Information ---{COLOR_RESET}") if self.context.get('modlist_source_type') == 'online_list': print(f"Modlist Source: Selected from online list") print(f"Modlist Identifier: {self.context.get('modlist_value')}") print(f"Detected Game: {self.context.get('modlist_game', 'N/A')}") elif self.context.get('modlist_source_type') == 'local_file': print(f"Modlist Source: Local .wabbajack file") print(f"File Path: {self.context.get('modlist_value')}") elif 'machineid' in self.context: # For Tuxborn/override flow print(f"Modlist Identifier (Tuxborn/MachineID): {self.context.get('machineid')}") print(f"Steam Shortcut Name: {self.context.get('modlist_name', 'N/A')}") install_dir_display = self.context.get('install_dir') if isinstance(install_dir_display, tuple): install_dir_display = install_dir_display[0] # Get the Path object from (Path, bool) print(f"Install Directory: {install_dir_display}") download_dir_display = self.context.get('download_dir') if isinstance(download_dir_display, tuple): download_dir_display = download_dir_display[0] # Get the Path object from (Path, bool) print(f"Download Directory: {download_dir_display}") if self.context.get('nexus_api_key'): print(f"Nexus API Key: [SET]") else: print(f"Nexus API Key: [NOT SET - WILL LIKELY FAIL]") print(f"{COLOR_INFO}----------------------------------------{COLOR_RESET}") def configuration_phase(self): """ Run the configuration phase: execute the Linux-native Jackify Install Engine. """ import subprocess import time import sys from pathlib import Path # UI Colors and LoggingHandler already imported at module level print(f"\n{COLOR_PROMPT}--- Configuration Phase: Installing Modlist ---{COLOR_RESET}") start_time = time.time() # --- BEGIN: TEE LOGGING SETUP & LOG ROTATION --- log_dir = Path.home() / "Jackify" / "logs" log_dir.mkdir(parents=True, exist_ok=True) workflow_log_path = log_dir / "Modlist_Install_workflow.log" # Log rotation: keep last 3 logs, 1MB each (adjust as needed) max_logs = 3 max_size = 1024 * 1024 # 1MB if workflow_log_path.exists() and workflow_log_path.stat().st_size > max_size: for i in range(max_logs, 0, -1): prev = log_dir / f"Modlist_Install_workflow.log.{i-1}" if i > 1 else workflow_log_path dest = log_dir / f"Modlist_Install_workflow.log.{i}" if prev.exists(): if dest.exists(): dest.unlink() prev.rename(dest) workflow_log = open(workflow_log_path, 'a') class TeeStdout: def __init__(self, *files): self.files = files def write(self, data): for f in self.files: f.write(data) f.flush() def flush(self): for f in self.files: f.flush() orig_stdout, orig_stderr = sys.stdout, sys.stderr sys.stdout = TeeStdout(sys.stdout, workflow_log) sys.stderr = TeeStdout(sys.stderr, workflow_log) # --- END: TEE LOGGING SETUP & LOG ROTATION --- try: # --- Process Paths from context --- install_dir_context = self.context['install_dir'] if isinstance(install_dir_context, tuple): actual_install_path = Path(install_dir_context[0]) if install_dir_context[1]: # Second element is True if creation was intended self.logger.info(f"Creating install directory as it was marked for creation: {actual_install_path}") actual_install_path.mkdir(parents=True, exist_ok=True) else: # Should be a Path object or string already actual_install_path = Path(install_dir_context) install_dir_str = str(actual_install_path) self.logger.debug(f"Processed install directory for engine: {install_dir_str}") download_dir_context = self.context['download_dir'] if isinstance(download_dir_context, tuple): actual_download_path = Path(download_dir_context[0]) if download_dir_context[1]: # Second element is True if creation was intended self.logger.info(f"Creating download directory as it was marked for creation: {actual_download_path}") actual_download_path.mkdir(parents=True, exist_ok=True) else: # Should be a Path object or string already actual_download_path = Path(download_dir_context) download_dir_str = str(actual_download_path) self.logger.debug(f"Processed download directory for engine: {download_dir_str}") # --- End Process Paths --- modlist_arg = self.context.get('modlist_value') or self.context.get('machineid') machineid = self.context.get('machineid') api_key = self.context['nexus_api_key'] # Path to the engine binary engine_path = get_jackify_engine_path() engine_dir = os.path.dirname(engine_path) if not os.path.isfile(engine_path) or not os.access(engine_path, os.X_OK): print(f"{COLOR_ERROR}Jackify Install Engine not found or not executable at: {engine_path}{COLOR_RESET}") return # --- Patch for GUI/auto: always set modlist_source to 'identifier' if not set, and ensure modlist_value is present --- if os.environ.get('JACKIFY_GUI_MODE') == '1': if not self.context.get('modlist_source'): self.context['modlist_source'] = 'identifier' if not self.context.get('modlist_value'): print(f"{COLOR_ERROR}ERROR: modlist_value is missing in context for GUI workflow!{COLOR_RESET}") self.logger.error("modlist_value is missing in context for GUI workflow!") return # --- End Patch --- # Build command cmd = [engine_path, 'install'] # Check for debug mode and pass --debug to engine if needed from jackify.backend.handlers.config_handler import ConfigHandler config_handler = ConfigHandler() debug_mode = config_handler.get('debug_mode', False) if debug_mode: cmd.append('--debug') self.logger.info("Debug mode enabled in config - passing --debug flag to jackify-engine") # Determine if this is a local .wabbajack file or an online modlist modlist_value = self.context.get('modlist_value') machineid = self.context.get('machineid') # Check if there's a cached .wabbajack file for this modlist cached_wabbajack_path = None if machineid: # Convert machineid to filename (e.g., "Tuxborn/Tuxborn" -> "Tuxborn.wabbajack") modlist_name = machineid.split('/')[-1] if '/' in machineid else machineid from jackify.shared.paths import get_jackify_downloads_dir cached_wabbajack_path = get_jackify_downloads_dir() / f"{modlist_name}.wabbajack" self.logger.debug(f"Checking for cached .wabbajack file: {cached_wabbajack_path}") if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value): cmd += ['-w', modlist_value] self.logger.info(f"Using local .wabbajack file: {modlist_value}") elif cached_wabbajack_path and os.path.isfile(cached_wabbajack_path): cmd += ['-w', cached_wabbajack_path] self.logger.info(f"Using cached .wabbajack file: {cached_wabbajack_path}") elif modlist_value: cmd += ['-m', modlist_value] self.logger.info(f"Using modlist identifier: {modlist_value}") elif machineid: cmd += ['-m', machineid] self.logger.info(f"Using machineid: {machineid}") cmd += ['-o', install_dir_str, '-d', download_dir_str] # Store original environment values to restore later original_env_values = { 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), 'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT') } try: # Temporarily modify current process's environment if api_key: os.environ['NEXUS_API_KEY'] = api_key self.logger.debug(f"Temporarily set os.environ['NEXUS_API_KEY'] for engine call using session-provided key.") elif 'NEXUS_API_KEY' in os.environ: # api_key is None/empty, but a system key might exist self.logger.debug(f"Session API key not provided. Temporarily removing inherited NEXUS_API_KEY ('{'[REDACTED]' if os.environ.get('NEXUS_API_KEY') else 'None'}') from os.environ for engine call to ensure it is not used.") del os.environ['NEXUS_API_KEY'] # If api_key is None and NEXUS_API_KEY was not in os.environ, it remains unset, which is correct. os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1" self.logger.debug(f"Temporarily set os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = '1' for engine call.") self.logger.info("Environment prepared for jackify-engine install process by modifying os.environ.") self.logger.debug(f"NEXUS_API_KEY in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_API_KEY') else '[NOT SET]'}") pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd]) print(f"{COLOR_INFO}Launching Jackify Install Engine with command:{COLOR_RESET} {pretty_cmd}") # Temporarily increase file descriptor limit for engine process from jackify.backend.handlers.subprocess_utils import increase_file_descriptor_limit success, old_limit, new_limit, message = increase_file_descriptor_limit() if success: self.logger.debug(f"File descriptor limit: {message}") else: self.logger.warning(f"File descriptor limit: {message}") # Popen now inherits the modified os.environ because env=None proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=None, cwd=engine_dir) # Start performance monitoring for the engine process # Adjust monitoring based on debug mode if debug_mode: # More aggressive monitoring in debug mode performance_monitor = EnginePerformanceMonitor( logger=self.logger, stall_threshold=5.0, # CPU below 5% is considered stalled stall_duration=60.0, # 1 minute of low CPU = stall (faster detection) sample_interval=5.0 # Check every 5 seconds (more frequent) ) # Add debug callback for detailed metrics from .engine_monitor import create_debug_callback performance_monitor.add_callback(create_debug_callback(self.logger)) self.logger.info("Enhanced performance monitoring enabled for debug mode") else: # Standard monitoring performance_monitor = EnginePerformanceMonitor( logger=self.logger, stall_threshold=5.0, # CPU below 5% is considered stalled stall_duration=120.0, # 2 minutes of low CPU = stall sample_interval=10.0 # Check every 10 seconds ) # Add callback to alert about performance issues def stall_alert(message: str): print(f"\nWarning: {message}") print("If the process appears stuck, you may need to restart it.") if debug_mode: print("Debug mode: Use 'python -m jackify.backend.handlers.diagnostic_helper' for detailed analysis") performance_monitor.add_callback(create_stall_alert_callback(self.logger, stall_alert)) # Start monitoring monitoring_started = performance_monitor.start_monitoring(proc.pid) if monitoring_started: self.logger.info(f"Performance monitoring started for engine PID {proc.pid}") else: self.logger.warning("Failed to start performance monitoring") try: # Read output in binary mode to properly handle carriage returns buffer = b'' last_progress_time = time.time() while True: chunk = proc.stdout.read(1) if not chunk: break buffer += chunk # Process complete lines or carriage return updates if chunk == b'\n': # Complete line - decode and print line = buffer.decode('utf-8', errors='replace') # Enhance Nexus download errors with modlist context enhanced_line = self._enhance_nexus_error(line) print(enhanced_line, end='') buffer = b'' last_progress_time = time.time() elif chunk == b'\r': # Carriage return - decode and print without newline line = buffer.decode('utf-8', errors='replace') # Enhance Nexus download errors with modlist context enhanced_line = self._enhance_nexus_error(line) print(enhanced_line, end='') sys.stdout.flush() buffer = b'' last_progress_time = time.time() # Check for timeout (no output for too long) current_time = time.time() if current_time - last_progress_time > 300: # 5 minutes no output self.logger.warning("No output from engine for 5 minutes - possible stall") last_progress_time = current_time # Reset to avoid spam # Print any remaining buffer content if buffer: line = buffer.decode('utf-8', errors='replace') print(line, end='') proc.wait() finally: # Stop performance monitoring and get summary if monitoring_started: performance_monitor.stop_monitoring() summary = performance_monitor.get_metrics_summary() if summary: self.logger.info(f"Engine Performance Summary: " f"Duration: {summary.get('monitoring_duration', 0):.1f}s, " f"Avg CPU: {summary.get('avg_cpu_percent', 0):.1f}%, " f"Max Memory: {summary.get('max_memory_mb', 0):.1f}MB, " f"Stalls: {summary.get('stall_percentage', 0):.1f}%") # Log detailed summary for debugging self.logger.debug(f"Detailed performance summary: {summary}") if proc.returncode != 0: print(f"{COLOR_ERROR}Jackify Install Engine exited with code {proc.returncode}.{COLOR_RESET}") self.logger.error(f"Engine exited with code {proc.returncode}.") return # Configuration phase failed self.logger.info(f"Engine completed with code {proc.returncode}.") except Exception as e: print(f"{COLOR_ERROR}Error running Jackify Install Engine: {e}{COLOR_RESET}\n") self.logger.error(f"Exception running engine: {e}", exc_info=True) return # Configuration phase failed finally: # Restore original environment state for key, original_value in original_env_values.items(): current_value_in_os_environ = os.environ.get(key) # Value after Popen and before our restoration for this key # Determine display values for logging, redacting NEXUS_API_KEY display_original_value = f"'[REDACTED]'" if key == 'NEXUS_API_KEY' else f"'{original_value}'" # display_current_value_before_restore = f"'[REDACTED]'" if key == 'NEXUS_API_KEY' else f"'{current_value_in_os_environ}'" if original_value is not None: # Original value existed. We must restore it. if current_value_in_os_environ != original_value: os.environ[key] = original_value self.logger.debug(f"Restored os.environ['{key}'] to its original value: {display_original_value}.") else: # If current value is already the original, ensure it's correctly set (os.environ[key] = original_value is harmless) os.environ[key] = original_value # Ensure it is set self.logger.debug(f"os.environ['{key}'] ('{display_original_value}') matched original value. Ensured restoration.") else: # Original value was None (key was not in os.environ initially). if key in os.environ: # If it's in os.environ now, it means we must have set it or it was set by other means. self.logger.debug(f"Original os.environ['{key}'] was not set. Removing current value ('{'[REDACTED]' if os.environ.get(key) and key == 'NEXUS_API_KEY' else os.environ.get(key)}') that was set for the call.") del os.environ[key] # If original_value was None and key is not in os.environ now, nothing to do. except Exception as e: print(f"{COLOR_ERROR}Error during Tuxborn installation workflow: {e}{COLOR_RESET}\n") self.logger.error(f"Exception in Tuxborn workflow: {e}", exc_info=True) return finally: # --- BEGIN: RESTORE STDOUT/STDERR --- sys.stdout = orig_stdout sys.stderr = orig_stderr workflow_log.close() # --- END: RESTORE STDOUT/STDERR --- elapsed = int(time.time() - start_time) print(f"\nElapsed time: {elapsed//3600:02d}:{(elapsed%3600)//60:02d}:{elapsed%60:02d} (hh:mm:ss)\n") print(f"{COLOR_INFO}Your modlist has been installed to: {install_dir_str}{COLOR_RESET}\n") if self.context.get('machineid') != 'Tuxborn/Tuxborn': print(f"{COLOR_WARNING}Only Skyrim, Fallout 4, Fallout New Vegas, Oblivion, Starfield, and Oblivion Remastered modlists are compatible with Jackify's post-install configuration. Any modlist can be downloaded/installed, but only these games are supported for automated configuration.{COLOR_RESET}") # After install, use self.context['modlist_game'] to determine if configuration should be offered # After install, detect game type from ModOrganizer.ini modorganizer_ini = os.path.join(install_dir_str, "ModOrganizer.ini") detected_game = None if os.path.isfile(modorganizer_ini): from .modlist_handler import ModlistHandler handler = ModlistHandler({}, steamdeck=self.steamdeck) handler.modlist_ini = modorganizer_ini handler.modlist_dir = install_dir_str if handler._detect_game_variables(): detected_game = handler.game_var_full supported_games = ["Skyrim Special Edition", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal"] is_tuxborn = self.context.get('machineid') == 'Tuxborn/Tuxborn' if (detected_game in supported_games) or is_tuxborn: shortcut_name = self.context.get('modlist_name') if is_tuxborn and not shortcut_name: self.logger.warning("Tuxborn is true, but shortcut_name (modlist_name in context) is missing. Defaulting to 'Tuxborn Automatic Installer'") shortcut_name = "Tuxborn Automatic Installer" # Provide a fallback default elif not shortcut_name: # For non-Tuxborn, prompt if missing print("\n" + "-" * 28) print(f"{COLOR_PROMPT}Please provide a name for the Steam shortcut for '{self.context.get('modlist_name', 'this modlist')}'.{COLOR_RESET}") raw_shortcut_name = input(f"{COLOR_PROMPT}Steam Shortcut Name (or 'q' to cancel): {COLOR_RESET} ").strip() if raw_shortcut_name.lower() == 'q' or not raw_shortcut_name: return shortcut_name = raw_shortcut_name # Check if GUI mode to skip interactive prompts is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1' if not is_gui_mode: # Prompt user if they want to configure Steam shortcut now print("\n" + "-" * 28) print(f"{COLOR_PROMPT}Would you like to add '{shortcut_name}' to Steam and configure it now?{COLOR_RESET}") configure_choice = input(f"{COLOR_PROMPT}Configure now? (Y/n): {COLOR_RESET}").strip().lower() if configure_choice == 'n': print(f"{COLOR_INFO}Skipping Steam configuration. You can configure it later using 'Configure New Modlist'.{COLOR_RESET}") return # Proceed with Steam configuration self.logger.info(f"Starting Steam configuration for '{shortcut_name}'") # Step 1: Create Steam shortcut first mo2_exe_path = os.path.join(install_dir_str, 'ModOrganizer.exe') # Use the working shortcut creation process from legacy code from .shortcut_handler import ShortcutHandler shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck, verbose=False) # Create nxmhandler.ini to suppress NXM popup shortcut_handler.write_nxmhandler_ini(install_dir_str, mo2_exe_path) # Create shortcut with working NativeSteamService from ..services.native_steam_service import NativeSteamService steam_service = NativeSteamService() success, app_id = steam_service.create_shortcut_with_proton( app_name=shortcut_name, exe_path=mo2_exe_path, start_dir=os.path.dirname(mo2_exe_path), launch_options="%command%", tags=["Jackify"], proton_version="proton_experimental" ) if not success or not app_id: self.logger.error("Failed to create Steam shortcut") print(f"{COLOR_ERROR}Failed to create Steam shortcut. Check logs for details.{COLOR_RESET}") return # Step 2: Handle Steam restart and manual steps (if not in GUI mode) if not is_gui_mode: print(f"\n{COLOR_INFO}Steam shortcut created successfully!{COLOR_RESET}") print("Steam needs to restart to detect the new shortcut.") restart_choice = input("\nRestart Steam automatically now? (Y/n): ").strip().lower() if restart_choice == 'n': print("\nPlease restart Steam manually and complete the Proton setup steps.") print("You can configure this modlist later using 'Configure Existing Modlist'.") return # Restart Steam print("\nRestarting Steam...") if shortcut_handler.secure_steam_restart(): print(f"{COLOR_INFO}Steam restarted successfully.{COLOR_RESET}") # Display manual Proton steps from .menu_handler import ModlistMenuHandler from .config_handler import ConfigHandler config_handler = ConfigHandler() menu_handler = ModlistMenuHandler(config_handler) menu_handler._display_manual_proton_steps(shortcut_name) input(f"\n{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}") # Get the updated AppID after launch new_app_id = shortcut_handler.get_appid_for_shortcut(shortcut_name, mo2_exe_path) if new_app_id and new_app_id.isdigit() and int(new_app_id) > 0: app_id = new_app_id else: print(f"{COLOR_ERROR}Could not find valid AppID after launch. Configuration may not work properly.{COLOR_RESET}") else: print(f"{COLOR_ERROR}Steam restart failed. Please restart manually and configure later.{COLOR_RESET}") return # Step 3: Build configuration context with the AppID config_context = { 'name': shortcut_name, 'appid': app_id, 'path': install_dir_str, 'mo2_exe_path': mo2_exe_path, 'resolution': self.context.get('resolution'), 'skip_confirmation': is_gui_mode, 'manual_steps_completed': not is_gui_mode # True if we did manual steps above } # Step 4: Use ModlistMenuHandler to run the complete configuration from .menu_handler import ModlistMenuHandler from .config_handler import ConfigHandler config_handler = ConfigHandler() modlist_menu = ModlistMenuHandler(config_handler) self.logger.info("Running post-installation configuration phase") configuration_success = modlist_menu.run_modlist_configuration_phase(config_context) if configuration_success: self.logger.info("Post-installation configuration completed successfully") # Check for TTW integration eligibility self._check_and_prompt_ttw_integration(install_dir_str, detected_game, modlist_name) else: self.logger.warning("Post-installation configuration had issues") else: # Game not supported for automated configuration print(f"{COLOR_INFO}Modlist installation complete.{COLOR_RESET}") if detected_game: print(f"{COLOR_WARNING}Detected game '{detected_game}' is not supported for automated Steam configuration.{COLOR_RESET}") else: print(f"{COLOR_WARNING}Could not detect game type from ModOrganizer.ini for automated configuration.{COLOR_RESET}") print(f"{COLOR_INFO}You may need to manually configure the modlist for Steam/Proton.{COLOR_RESET}") def install_modlist(self, selected_modlist_info: Optional[Dict[str, Any]] = None, wabbajack_file_path: Optional[Union[str, Path]] = None): # This is where we would get the engine path for the actual installation engine_path = get_jackify_engine_path() # Use the helper self.logger.info(f"Using engine path for installation: {engine_path}") # --- The rest of your install_modlist logic --- # ... # When constructing the subprocess command for install, use `engine_path` # For example: # install_command = [engine_path, 'install', '--modlist-url', modlist_url, ...] # ... self.logger.info("Placeholder for actual modlist installation logic using the engine.") print("Modlist installation logic would run here.") return True # Placeholder def _get_nexus_api_key(self) -> Optional[str]: # This method is not provided in the original file or the code block # It's assumed to exist as it's called in the _display_summary method # Implement the logic to retrieve the Nexus API key from the context return self.context.get('nexus_api_key') def get_all_modlists_from_engine(self, game_type=None): """ Call the Jackify engine with 'list-modlists' and return a list of modlist dicts. Each dict should have at least 'id', 'game', 'download_size', 'install_size', 'total_size', and status flags. Args: game_type (str, optional): Filter by game type (e.g., "Skyrim", "Fallout New Vegas") """ import subprocess import re from pathlib import Path # COLOR_ERROR already imported at module level engine_executable = get_jackify_engine_path() engine_dir = os.path.dirname(engine_executable) if not os.path.exists(engine_executable): self.logger.error(f"jackify-install-engine not found at {engine_executable}") print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_ERROR}") return [] env = os.environ.copy() env["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1" command = [engine_executable, 'list-modlists', '--show-all-sizes', '--show-machine-url'] # Add game filter if specified if game_type: command.extend(['--game', game_type]) try: result = subprocess.run( command, capture_output=True, text=True, check=True, env=env, cwd=engine_dir ) lines = result.stdout.splitlines() modlists = [] for line in lines: line = line.strip() if not line or line.startswith('Loading') or line.startswith('Loaded'): continue # Parse the new format: [STATUS] Modlist Name - Game - Download|Install|Total - MachineURL # STATUS indicators: [DOWN], [NSFW], or both [DOWN] [NSFW] # Extract status indicators status_down = '[DOWN]' in line status_nsfw = '[NSFW]' in line # Remove status indicators to get clean line clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip() # Split from right to handle modlist names with dashes # Format: "NAME - GAME - SIZES - MACHINE_URL" parts = clean_line.rsplit(' - ', 3) # Split from right, max 3 splits = 4 parts if len(parts) != 4: continue # Skip malformed lines modlist_name = parts[0].strip() game_name = parts[1].strip() sizes_str = parts[2].strip() machine_url = parts[3].strip() # Parse sizes: "Download|Install|Total" (e.g., "203GB|130GB|333GB") size_parts = sizes_str.split('|') if len(size_parts) != 3: continue # Skip if sizes don't match expected format download_size = size_parts[0].strip() install_size = size_parts[1].strip() total_size = size_parts[2].strip() # Skip if any required data is missing if not modlist_name or not game_name or not machine_url: continue modlists.append({ 'id': modlist_name, # Use modlist name as ID for compatibility 'name': modlist_name, 'game': game_name, 'download_size': download_size, 'install_size': install_size, 'total_size': total_size, 'machine_url': machine_url, # Store machine URL for installation 'status_down': status_down, 'status_nsfw': status_nsfw }) return modlists except subprocess.CalledProcessError as e: self.logger.error(f"list-modlists failed. Code: {e.returncode}") if e.stdout: self.logger.error(f"Engine stdout:\n{e.stdout}") if e.stderr: self.logger.error(f"Engine stderr:\n{e.stderr}") print(f"{COLOR_ERROR}Failed to fetch modlist list. Engine error (Code: {e.returncode}).{COLOR_ERROR}") return [] except Exception as e: self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True) print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_ERROR}") return [] def _display_summary(self): # REMOVE pass AND RESTORE THE METHOD BODY # print(f"{COLOR_WARNING}DEBUG: _display_summary called. Current context keys: {list(self.context.keys())}{COLOR_RESET}") # Keep commented # self.logger.info(f"DEBUG: _display_summary called. Current context keys: {list(self.context.keys())}") # Keep commented print(f"\n{COLOR_INFO}--- Summary of Collected Information ---{COLOR_RESET}") if self.context.get('modlist_source_type') == 'online_list': print(f"Modlist Source: Selected from online list") print(f"Modlist Identifier: {self.context.get('modlist_value')}") print(f"Detected Game: {self.context.get('modlist_game', 'N/A')}") elif self.context.get('modlist_source_type') == 'local_file': print(f"Modlist Source: Local .wabbajack file") print(f"File Path: {self.context.get('modlist_value')}") elif 'machineid' in self.context: # For Tuxborn/override flow print(f"Modlist Identifier (Tuxborn/MachineID): {self.context.get('machineid')}") print(f"Steam Shortcut Name: {self.context.get('modlist_name', 'N/A')}") install_dir_display = self.context.get('install_dir') if isinstance(install_dir_display, tuple): install_dir_display = install_dir_display[0] # Get the Path object from (Path, bool) print(f"Install Directory: {install_dir_display}") download_dir_display = self.context.get('download_dir') if isinstance(download_dir_display, tuple): download_dir_display = download_dir_display[0] # Get the Path object from (Path, bool) print(f"Download Directory: {download_dir_display}") if self.context.get('nexus_api_key'): print(f"Nexus API Key: [SET]") else: print(f"Nexus API Key: [NOT SET - WILL LIKELY FAIL]") print(f"{COLOR_INFO}----------------------------------------{COLOR_RESET}") def _enhance_nexus_error(self, line: str) -> str: """ Enhance Nexus download error messages by adding the mod URL for easier troubleshooting. """ import re # Pattern to match Nexus download errors with ModID and FileID nexus_error_pattern = r"Failed to download '[^']+' from Nexus \(Game: ([^,]+), ModID: (\d+), FileID: \d+\):" match = re.search(nexus_error_pattern, line) if match: game_name = match.group(1) mod_id = match.group(2) # Map game names to Nexus URL segments game_url_map = { 'SkyrimSpecialEdition': 'skyrimspecialedition', 'Skyrim': 'skyrim', 'Fallout4': 'fallout4', 'FalloutNewVegas': 'newvegas', 'Oblivion': 'oblivion', 'Starfield': 'starfield' } game_url = game_url_map.get(game_name, game_name.lower()) mod_url = f"https://www.nexusmods.com/{game_url}/mods/{mod_id}" # Add URL on next line for easier debugging return f"{line}\n Nexus URL: {mod_url}" return line def _check_and_prompt_ttw_integration(self, install_dir: str, game_type: str, modlist_name: str): """Check if modlist is eligible for TTW integration and prompt user""" try: # Check eligibility: FNV game, TTW-compatible modlist, no existing TTW if not self._is_ttw_eligible(install_dir, game_type, modlist_name): return # Prompt user for TTW installation print(f"\n{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}") print(f"{COLOR_INFO}TTW Integration Available{COLOR_RESET}") print(f"{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}") print(f"\nThis modlist ({modlist_name}) supports Tale of Two Wastelands (TTW).") print(f"TTW combines Fallout 3 and New Vegas into a single game.") print(f"\nWould you like to install TTW now?") user_input = input(f"{COLOR_PROMPT}Install TTW? (yes/no): {COLOR_RESET}").strip().lower() if user_input in ['yes', 'y']: self._launch_ttw_installation(modlist_name, install_dir) else: print(f"{COLOR_INFO}Skipping TTW installation. You can install it later from the main menu.{COLOR_RESET}") except Exception as e: self.logger.error(f"Error during TTW eligibility check: {e}", exc_info=True) def _is_ttw_eligible(self, install_dir: str, game_type: str, modlist_name: str) -> bool: """Check if modlist is eligible for TTW integration""" try: from pathlib import Path # Check 1: Must be Fallout New Vegas if not game_type or game_type.lower() not in ['falloutnv', 'fallout new vegas', 'fallout_new_vegas']: return False # Check 2: Must be on TTW compatibility whitelist from jackify.backend.data.ttw_compatible_modlists import is_ttw_compatible if not is_ttw_compatible(modlist_name): return False # Check 3: TTW must not already be installed if self._detect_existing_ttw(install_dir): self.logger.info(f"TTW already installed in {install_dir}, skipping prompt") return False return True except Exception as e: self.logger.error(f"Error checking TTW eligibility: {e}") return False def _detect_existing_ttw(self, install_dir: str) -> bool: """Detect if TTW is already installed in the modlist""" try: from pathlib import Path install_path = Path(install_dir) # Search for TTW indicators in common locations search_paths = [ install_path, install_path / "mods", install_path / "Stock Game", install_path / "Game Root" ] for search_path in search_paths: if not search_path.exists(): continue # Look for folders containing "tale" and "two" and "wastelands" for folder in search_path.iterdir(): if not folder.is_dir(): continue folder_name_lower = folder.name.lower() if all(keyword in folder_name_lower for keyword in ['tale', 'two', 'wastelands']): # Verify it has the TTW ESM file for file in folder.rglob('*.esm'): if 'taleoftwowastelands' in file.name.lower(): self.logger.info(f"Found existing TTW installation: {file}") return True return False except Exception as e: self.logger.error(f"Error detecting existing TTW: {e}") return False def _launch_ttw_installation(self, modlist_name: str, install_dir: str): """Launch TTW installation workflow""" try: print(f"\n{COLOR_INFO}Starting TTW installation workflow...{COLOR_RESET}") # Import TTW installation handler from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler from jackify.backend.models.configuration import SystemInfo system_info = SystemInfo() hoolamike_handler = HoolamikeHandler(system_info) # Check if Hoolamike is installed is_installed, installed_version = hoolamike_handler.check_installation_status() if not is_installed: print(f"{COLOR_INFO}Hoolamike (TTW installer) is not installed.{COLOR_RESET}") user_input = input(f"{COLOR_PROMPT}Install Hoolamike? (yes/no): {COLOR_RESET}").strip().lower() if user_input not in ['yes', 'y']: print(f"{COLOR_INFO}TTW installation cancelled.{COLOR_RESET}") return # Install Hoolamike print(f"{COLOR_INFO}Installing Hoolamike...{COLOR_RESET}") success, message = hoolamike_handler.install_hoolamike() if not success: print(f"{COLOR_ERROR}Failed to install Hoolamike: {message}{COLOR_RESET}") return print(f"{COLOR_INFO}Hoolamike installed successfully.{COLOR_RESET}") # Get Hoolamike MPI path mpi_path = hoolamike_handler.get_mpi_path() if not mpi_path or not os.path.exists(mpi_path): print(f"{COLOR_ERROR}Hoolamike MPI file not found at: {mpi_path}{COLOR_RESET}") return # Prompt for TTW installation directory print(f"\n{COLOR_PROMPT}TTW Installation Directory{COLOR_RESET}") print(f"Default: {os.path.join(install_dir, 'TTW')}") ttw_install_dir = input(f"{COLOR_PROMPT}TTW install directory (Enter for default): {COLOR_RESET}").strip() if not ttw_install_dir: ttw_install_dir = os.path.join(install_dir, "TTW") # Run Hoolamike installation print(f"\n{COLOR_INFO}Installing TTW using Hoolamike...{COLOR_RESET}") print(f"{COLOR_INFO}This may take a while (15-30 minutes depending on your system).{COLOR_RESET}") success = hoolamike_handler.run_hoolamike_install(mpi_path, ttw_install_dir) if success: print(f"\n{COLOR_INFO}═══════════════════════════════════════════════════════════════{COLOR_RESET}") print(f"{COLOR_INFO}TTW Installation Complete!{COLOR_RESET}") print(f"{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}") print(f"\nTTW has been installed to: {ttw_install_dir}") print(f"The modlist '{modlist_name}' is now ready to use with TTW.") else: print(f"\n{COLOR_ERROR}TTW installation failed. Check the logs for details.{COLOR_RESET}") except Exception as e: self.logger.error(f"Error during TTW installation: {e}", exc_info=True) print(f"{COLOR_ERROR}Error during TTW installation: {e}{COLOR_RESET}")