mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-01-17 19:47:00 +01:00
1138 lines
64 KiB
Python
1138 lines
64 KiB
Python
"""
|
|
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 <bundle_root>/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")
|
|
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 |