Sync from development - prepare for v0.3.0

This commit is contained in:
Omni
2026-02-07 18:26:54 +00:00
parent b55e1cf768
commit 12294d3186
169 changed files with 31749 additions and 33649 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,546 @@
"""CLI configuration phase methods for ModlistInstallCLI (Mixin)."""
import logging
import os
import subprocess
import sys
import time
from pathlib import Path
from ..handlers.ui_colors import (
COLOR_PROMPT,
COLOR_RESET,
COLOR_INFO,
COLOR_ERROR,
COLOR_SUCCESS,
COLOR_WARNING,
)
logger = logging.getLogger(__name__)
class ModlistOperationsConfigurationCLIMixin:
"""Mixin providing CLI configuration phase methods."""
def configuration_phase(self):
"""
Run the configuration phase: execute the Linux-native Jackify Install Engine.
"""
from .modlist_operations import get_jackify_engine_path
print(f"\n{COLOR_PROMPT}--- Configuration Phase: Installing Modlist ---{COLOR_RESET}")
start_time = time.time()
from jackify.shared.paths import get_jackify_logs_dir
log_dir = get_jackify_logs_dir()
log_dir.mkdir(parents=True, exist_ok=True)
workflow_log_path = log_dir / "Modlist_Install_workflow.log"
max_logs = 3
max_size = 1024 * 1024
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)
try:
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]:
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:
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]:
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:
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}")
modlist_arg = self.context.get('modlist_value') or self.context.get('machineid')
machineid = self.context.get('machineid')
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
current_api_key, current_oauth_info = auth_service.get_auth_for_engine()
api_key = current_api_key or self.context.get('nexus_api_key')
oauth_info = current_oauth_info or self.context.get('nexus_oauth_info')
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
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'):
self.logger.error("modlist_value is missing in context for GUI workflow!")
return
cmd = [engine_path, 'install', '--show-file-progress']
modlist_value = self.context.get('modlist_value')
if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value):
cmd += ['-w', modlist_value]
elif modlist_value:
cmd += ['-m', modlist_value]
elif self.context.get('machineid'):
cmd += ['-m', self.context['machineid']]
cmd += ['-o', install_dir_str, '-d', download_dir_str]
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("Adding --debug flag to jackify-engine")
original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
}
try:
if oauth_info:
os.environ['NEXUS_OAUTH_INFO'] = oauth_info
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
self.logger.debug(f"Set NEXUS_OAUTH_INFO and NEXUS_OAUTH_CLIENT_ID={NexusOAuthService.CLIENT_ID} for engine (supports auto-refresh)")
if api_key:
os.environ['NEXUS_API_KEY'] = api_key
elif api_key:
os.environ['NEXUS_API_KEY'] = api_key
self.logger.debug(f"Set NEXUS_API_KEY for engine (no auto-refresh)")
else:
if 'NEXUS_API_KEY' in os.environ:
del os.environ['NEXUS_API_KEY']
if 'NEXUS_OAUTH_INFO' in os.environ:
del os.environ['NEXUS_OAUTH_INFO']
if 'NEXUS_OAUTH_CLIENT_ID' in os.environ:
del os.environ['NEXUS_OAUTH_CLIENT_ID']
self.logger.debug(f"No Nexus auth available, cleared inherited env vars")
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]'}")
self.logger.debug(f"NEXUS_OAUTH_INFO in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_OAUTH_INFO') 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}")
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}")
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
clean_env = get_clean_subprocess_env()
self._current_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=clean_env, cwd=engine_dir)
proc = self._current_process
buffer = b''
while True:
chunk = proc.stdout.read(1)
if not chunk:
break
buffer += chunk
if chunk == b'\n':
line = buffer.decode('utf-8', errors='replace')
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
buffer = b''
continue
print(line, end='')
buffer = b''
elif chunk == b'\r':
line = buffer.decode('utf-8', errors='replace')
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
buffer = b''
continue
print(line, end='')
sys.stdout.flush()
buffer = b''
if buffer:
line = buffer.decode('utf-8', errors='replace')
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
line = ''
if line:
print(line, end='')
proc.wait()
self._current_process = None
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
self.logger.info(f"Engine completed with code {proc.returncode}.")
except Exception as e:
error_message = str(e)
print(f"{COLOR_ERROR}Error running Jackify Install Engine: {error_message}{COLOR_RESET}\n")
self.logger.error(f"Exception running engine: {error_message}", exc_info=True)
try:
from jackify.backend.services.resource_manager import handle_file_descriptor_error
if any(indicator in error_message.lower() for indicator in ['too many open files', 'emfile', 'resource temporarily unavailable']):
result = handle_file_descriptor_error(error_message, "Jackify Install Engine execution")
if result['auto_fix_success']:
print(f"{COLOR_INFO}File descriptor limit increased automatically. {result['recommendation']}{COLOR_RESET}")
self.logger.info(f"File descriptor limit increased automatically. {result['recommendation']}")
elif result['error_detected']:
print(f"{COLOR_WARNING}File descriptor limit issue detected. {result['recommendation']}{COLOR_RESET}")
self.logger.warning(f"File descriptor limit issue detected but automatic fix failed. {result['recommendation']}")
if result['manual_instructions']:
distro = result['manual_instructions']['distribution']
print(f"{COLOR_INFO}Manual ulimit increase instructions available for {distro} distribution{COLOR_RESET}")
self.logger.info(f"Manual ulimit increase instructions available for {distro} distribution")
except Exception as resource_error:
self.logger.debug(f"Error checking for resource limit issues: {resource_error}")
return
finally:
for key, original_value in original_env_values.items():
current_value_in_os_environ = os.environ.get(key)
display_original_value = f"'[REDACTED]'" if key == 'NEXUS_API_KEY' else f"'{original_value}'"
if original_value is not None:
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:
os.environ[key] = original_value
self.logger.debug(f"os.environ['{key}'] ('{display_original_value}') matched original value. Ensured restoration.")
else:
if key in os.environ:
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]
except Exception as e:
error_message = str(e)
print(f"{COLOR_ERROR}Error during installation workflow: {error_message}{COLOR_RESET}\n")
self.logger.error(f"Exception in installation workflow: {error_message}", exc_info=True)
try:
from jackify.backend.services.resource_manager import handle_file_descriptor_error
if any(indicator in error_message.lower() for indicator in ['too many open files', 'emfile', 'resource temporarily unavailable']):
result = handle_file_descriptor_error(error_message, "installation workflow")
if result['auto_fix_success']:
print(f"{COLOR_INFO}File descriptor limit increased automatically. {result['recommendation']}{COLOR_RESET}")
self.logger.info(f"File descriptor limit increased automatically. {result['recommendation']}")
elif result['error_detected']:
print(f"{COLOR_WARNING}File descriptor limit issue detected. {result['recommendation']}{COLOR_RESET}")
self.logger.warning(f"File descriptor limit issue detected but automatic fix failed. {result['recommendation']}")
if result['manual_instructions']:
distro = result['manual_instructions']['distribution']
print(f"{COLOR_INFO}Manual ulimit increase instructions available for {distro} distribution{COLOR_RESET}")
self.logger.info(f"Manual ulimit increase instructions available for {distro} distribution")
except Exception as resource_error:
self.logger.debug(f"Error checking for resource limit issues: {resource_error}")
return
finally:
sys.stdout = orig_stdout
sys.stderr = orig_stderr
workflow_log.close()
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}")
self.logger.debug("configuration_phase: Starting post-install game detection...")
modorganizer_ini = os.path.join(install_dir_str, "ModOrganizer.ini")
detected_game = None
self.logger.debug(f"configuration_phase: Looking for ModOrganizer.ini at: {modorganizer_ini}")
if os.path.isfile(modorganizer_ini):
self.logger.debug("configuration_phase: Found ModOrganizer.ini, detecting game...")
from ..handlers.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
self.logger.debug(f"configuration_phase: Detected game: {detected_game}")
else:
self.logger.debug("configuration_phase: Failed to detect game variables")
else:
self.logger.debug("configuration_phase: ModOrganizer.ini not found")
supported_games = ["Skyrim Special Edition", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal"]
is_tuxborn = self.context.get('machineid') == 'Tuxborn/Tuxborn'
self.logger.debug(f"configuration_phase: detected_game='{detected_game}', is_tuxborn={is_tuxborn}")
self.logger.debug(f"configuration_phase: Checking condition: (detected_game in supported_games) or is_tuxborn")
self.logger.debug(f"configuration_phase: Result: {(detected_game in supported_games) or is_tuxborn}")
if (detected_game in supported_games) or is_tuxborn:
self.logger.debug("configuration_phase: Entering Steam configuration workflow...")
shortcut_name = self.context.get('modlist_name')
self.logger.debug(f"configuration_phase: shortcut_name from context: '{shortcut_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"
elif not shortcut_name:
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:
self.logger.debug("configuration_phase: User cancelled shortcut name input")
return
shortcut_name = raw_shortcut_name
self.logger.debug(f"configuration_phase: Final shortcut_name: '{shortcut_name}'")
is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
self.logger.debug(f"configuration_phase: is_gui_mode={is_gui_mode}")
if not is_gui_mode:
self.logger.debug("configuration_phase: Not in GUI mode, prompting user for configuration...")
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()
self.logger.debug(f"configuration_phase: User choice: '{configure_choice}'")
if configure_choice == 'n':
print(f"{COLOR_INFO}Skipping Steam configuration. You can configure it later using 'Configure New Modlist'.{COLOR_RESET}")
self.logger.debug("configuration_phase: User chose to skip Steam configuration")
return
else:
self.logger.debug("configuration_phase: In GUI mode, proceeding automatically...")
self.logger.debug("configuration_phase: Proceeding with Steam configuration...")
if not is_gui_mode:
from jackify.backend.handlers.resolution_handler import ResolutionHandler
resolution_handler = ResolutionHandler()
is_steamdeck = self.steamdeck if hasattr(self, 'steamdeck') else False
selected_resolution = resolution_handler.select_resolution(steamdeck=is_steamdeck)
if selected_resolution:
self.context['resolution'] = selected_resolution
self.logger.info(f"Resolution set to: {selected_resolution}")
self.logger.info(f"Starting Steam configuration for '{shortcut_name}'")
mo2_exe_path = os.path.join(install_dir_str, 'ModOrganizer.exe')
app_id = None
use_automated_prefix = os.environ.get('JACKIFY_USE_AUTOMATED_PREFIX', '1') == '1'
if use_automated_prefix:
print(f"\n{COLOR_INFO}Using automated Steam setup workflow...{COLOR_RESET}")
from ..services.automated_prefix_service import AutomatedPrefixService
prefix_service = AutomatedPrefixService()
start_time = time.time()
def progress_callback(message):
elapsed = time.time() - start_time
hours = int(elapsed // 3600)
minutes = int((elapsed % 3600) // 60)
seconds = int(elapsed % 60)
timestamp = f"[{hours:02d}:{minutes:02d}:{seconds:02d}]"
print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}")
try:
_is_steamdeck = False
if os.path.exists('/etc/os-release'):
with open('/etc/os-release') as f:
if 'steamdeck' in f.read().lower():
_is_steamdeck = True
except Exception:
_is_steamdeck = False
result = prefix_service.run_working_workflow(
shortcut_name, install_dir_str, mo2_exe_path, progress_callback, steamdeck=_is_steamdeck
)
if isinstance(result, tuple) and len(result) == 4:
if result[0] == "CONFLICT":
conflicts = result[1]
print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}")
for i, conflict in enumerate(conflicts, 1):
print(f" {i}. Name: {conflict['name']}")
print(f" Executable: {conflict['exe']}")
print(f" Start Directory: {conflict['startdir']}")
print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}")
print(" * Replace - Remove the existing shortcut and create a new one")
print(" * Cancel - Keep the existing shortcut and stop the installation")
print(" * Skip - Continue without creating a Steam shortcut")
choice = input(f"\n{COLOR_PROMPT}Choose an option (replace/cancel/skip): {COLOR_RESET}").strip().lower()
if choice == 'replace':
print(f"{COLOR_INFO}Replacing existing shortcut...{COLOR_RESET}")
success, app_id = prefix_service.replace_existing_shortcut(shortcut_name, mo2_exe_path, install_dir_str)
if success and app_id:
result = prefix_service.continue_workflow_after_conflict_resolution(
shortcut_name, install_dir_str, mo2_exe_path, app_id, progress_callback
)
if isinstance(result, tuple) and len(result) >= 3:
success, prefix_path, app_id = result[0], result[1], result[2]
else:
success, prefix_path, app_id = False, None, None
else:
success, prefix_path, app_id = False, None, None
elif choice == 'cancel':
print(f"{COLOR_INFO}Cancelling installation.{COLOR_RESET}")
return
elif choice == 'skip':
print(f"{COLOR_INFO}Skipping Steam shortcut creation.{COLOR_RESET}")
success, prefix_path, app_id = True, None, None
else:
print(f"{COLOR_ERROR}Invalid choice. Cancelling.{COLOR_RESET}")
return
else:
success, prefix_path, app_id, last_timestamp = result
elif isinstance(result, tuple) and len(result) == 3:
if result[0] == "CONFLICT":
conflicts = result[1]
print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}")
for i, conflict in enumerate(conflicts, 1):
print(f" {i}. Name: {conflict['name']}")
print(f" Executable: {conflict['exe']}")
print(f" Start Directory: {conflict['startdir']}")
print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}")
print(" * Replace - Remove the existing shortcut and create a new one")
print(" * Cancel - Keep the existing shortcut and stop the installation")
print(" * Skip - Continue without creating a Steam shortcut")
choice = input(f"\n{COLOR_PROMPT}Choose an option (replace/cancel/skip): {COLOR_RESET}").strip().lower()
if choice == 'replace':
print(f"{COLOR_INFO}Replacing existing shortcut...{COLOR_RESET}")
success, app_id = prefix_service.replace_existing_shortcut(shortcut_name, mo2_exe_path, install_dir_str)
if success and app_id:
result = prefix_service.continue_workflow_after_conflict_resolution(
shortcut_name, install_dir_str, mo2_exe_path, app_id, progress_callback
)
if isinstance(result, tuple) and len(result) >= 3:
success, prefix_path, app_id = result[0], result[1], result[2]
else:
success, prefix_path, app_id = False, None, None
else:
success, prefix_path, app_id = False, None, None
elif choice == 'cancel':
print(f"{COLOR_INFO}Cancelling installation.{COLOR_RESET}")
return
elif choice == 'skip':
print(f"{COLOR_INFO}Skipping Steam shortcut creation.{COLOR_RESET}")
success, prefix_path, app_id = True, None, None
else:
print(f"{COLOR_ERROR}Invalid choice. Cancelling.{COLOR_RESET}")
return
else:
success, prefix_path, app_id = result
else:
if result is True:
success, prefix_path, app_id = True, None, None
else:
success, prefix_path, app_id = False, None, None
if success:
print(f"{COLOR_SUCCESS}Automated Steam setup completed successfully!{COLOR_RESET}")
if prefix_path:
print(f"{COLOR_INFO}Proton prefix created at: {prefix_path}{COLOR_RESET}")
if app_id:
print(f"{COLOR_INFO}Steam AppID: {app_id}{COLOR_RESET}")
else:
print(f"{COLOR_ERROR}Automated Steam setup failed. Result: {result}{COLOR_RESET}")
print(f"{COLOR_ERROR}Steam integration was not completed. Please check the logs for details.{COLOR_RESET}")
return
from jackify.backend.services.modlist_service import ModlistService
from jackify.backend.models.modlist import ModlistContext
modlist_context = ModlistContext(
name=shortcut_name,
install_dir=Path(install_dir_str),
download_dir=Path(install_dir_str) / "downloads",
game_type=self.context.get('detected_game', 'Unknown'),
nexus_api_key='',
modlist_value=self.context.get('modlist_value', ''),
modlist_source=self.context.get('modlist_source', 'identifier'),
resolution=self.context.get('resolution'),
mo2_exe_path=Path(mo2_exe_path),
skip_confirmation=True,
engine_installed=True
)
modlist_context.app_id = app_id
modlist_service = ModlistService(self.system_info)
if 'progress_callback' in locals() and progress_callback:
progress_callback("")
progress_callback("=== Configuration Phase ===")
print(f"\n{COLOR_INFO}=== Configuration Phase ==={COLOR_RESET}")
self.logger.info("Running post-installation configuration phase using ModlistService")
configuration_success = modlist_service.configure_modlist_post_steam(modlist_context)
if configuration_success:
print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}")
self.logger.info("Post-installation configuration completed successfully")
else:
print(f"{COLOR_WARNING}Configuration had some issues but completed.{COLOR_RESET}")
self.logger.warning("Post-installation configuration had issues")
else:
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}")

View File

@@ -0,0 +1,170 @@
"""GUI configuration phase methods for ModlistInstallCLI (Mixin)."""
import logging
import os
logger = logging.getLogger(__name__)
class ModlistOperationsConfigurationGUIMixin:
"""Mixin providing GUI configuration phase methods."""
def configuration_phase_gui_mode(self, context,
progress_callback=None,
manual_steps_callback=None,
completion_callback=None):
"""
GUI-friendly configuration phase that uses callbacks instead of prompts.
This method provides the same functionality as configuration_phase() but
integrates with GUI frontends using Qt callbacks instead of CLI prompts.
Args:
context: Configuration context dict with modlist details
progress_callback: Called with progress messages (str)
manual_steps_callback: Called when manual steps needed (modlist_name, retry_count)
completion_callback: Called when configuration completes (success, message, modlist_name)
"""
try:
from .modlist_operations import _get_user_proton_version
original_gui_mode = os.environ.get('JACKIFY_GUI_MODE')
try:
config_context = {
'name': context.get('modlist_name', ''),
'path': context.get('install_dir', ''),
'mo2_exe_path': context.get('mo2_exe_path', ''),
'modlist_value': context.get('modlist_value'),
'modlist_source': context.get('modlist_source'),
'resolution': context.get('resolution'),
'skip_confirmation': True,
'manual_steps_completed': False
}
existing_app_id = context.get('app_id')
if existing_app_id:
config_context['appid'] = existing_app_id
if progress_callback:
progress_callback(f"Configuring existing modlist with AppID {existing_app_id}...")
from jackify.backend.handlers.menu_handler import ModlistMenuHandler
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
modlist_menu = ModlistMenuHandler(config_handler)
retry_count = 0
max_retries = 3
while retry_count < max_retries:
if progress_callback:
progress_callback("Running modlist configuration...")
result = modlist_menu.run_modlist_configuration_phase(config_context)
if progress_callback:
progress_callback(f"Configuration attempt {retry_count}: {'Success' if result else 'Failed'}")
if result:
if completion_callback:
completion_callback(True, "Configuration completed successfully!", config_context['name'])
return True
else:
retry_count += 1
if retry_count < max_retries:
if progress_callback:
progress_callback(f"Configuration failed on attempt {retry_count}, showing manual steps dialog...")
if manual_steps_callback:
if progress_callback:
progress_callback(f"Calling manual_steps_callback for {config_context['name']}, retry {retry_count}")
manual_steps_callback(config_context['name'], retry_count)
config_context['manual_steps_completed'] = True
else:
if completion_callback:
completion_callback(False, "Manual steps failed after multiple attempts", config_context['name'])
return False
if completion_callback:
completion_callback(False, "Configuration failed", config_context['name'])
return False
else:
from jackify.backend.handlers.menu_handler import ModlistMenuHandler
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
modlist_menu = ModlistMenuHandler(config_handler)
if progress_callback:
progress_callback("Creating Steam shortcut...")
from jackify.backend.services.native_steam_service import NativeSteamService
steam_service = NativeSteamService()
proton_version = _get_user_proton_version()
success, app_id = steam_service.create_shortcut_with_proton(
app_name=config_context['name'],
exe_path=config_context['mo2_exe_path'],
start_dir=os.path.dirname(config_context['mo2_exe_path']),
launch_options="%command%",
tags=["Jackify"],
proton_version=proton_version
)
if not success or not app_id:
if completion_callback:
completion_callback(False, "Failed to create Steam shortcut", config_context['name'])
return False
config_context['appid'] = app_id
if progress_callback:
from jackify.shared.timing import get_timestamp
progress_callback(f"{get_timestamp()} Steam shortcut created successfully")
if progress_callback:
progress_callback("Running modlist configuration...")
if progress_callback:
progress_callback(f"About to call run_modlist_configuration_phase with context: {config_context}")
result = modlist_menu.run_modlist_configuration_phase(config_context)
if progress_callback:
progress_callback(f"run_modlist_configuration_phase returned: {result}")
if result:
if completion_callback:
completion_callback(True, "Configuration completed successfully!", config_context['name'])
return True
else:
if progress_callback:
progress_callback("Configuration failed, manual Steam/Proton setup required")
if manual_steps_callback:
if progress_callback:
progress_callback(f"About to call manual_steps_callback for {config_context['name']}, retry 1")
manual_steps_callback(config_context['name'], 1)
if progress_callback:
progress_callback("manual_steps_callback completed")
return True
if completion_callback:
completion_callback(False, "Configuration failed", config_context['name'])
return False
finally:
if original_gui_mode is not None:
os.environ['JACKIFY_GUI_MODE'] = original_gui_mode
else:
os.environ.pop('JACKIFY_GUI_MODE', None)
except Exception as e:
error_msg = f"Configuration failed: {str(e)}"
if completion_callback:
completion_callback(False, error_msg, context.get('modlist_name', 'Unknown'))
return False

View File

@@ -0,0 +1,368 @@
"""Discovery phase methods for ModlistInstallCLI (Mixin)."""
import logging
import os
from pathlib import Path
from typing import Optional, Dict
from ..handlers.ui_colors import (
COLOR_PROMPT,
COLOR_RESET,
COLOR_INFO,
COLOR_ERROR,
COLOR_SUCCESS,
COLOR_WARNING,
COLOR_SELECTION,
)
from ..handlers.config_handler import ConfigHandler
from jackify.backend.models.configuration import SystemInfo
from jackify.backend.services.modlist_service import ModlistService
logger = logging.getLogger(__name__)
class ModlistOperationsDiscoveryMixin:
"""Mixin providing modlist discovery phase methods."""
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).
"""
from .modlist_operations import get_jackify_engine_path
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'
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:
self.logger.error(f"Missing required arguments for GUI workflow: {', '.join(missing)}")
if not has_modlist:
self.logger.error("Missing modlist_value or machineid for GUI workflow.")
self.logger.error("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
engine_executable = get_jackify_engine_path()
self.logger.debug(f"Engine executable path: {engine_executable}")
if not os.path.exists(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)
if 'machineid' not in self.context:
print("\n" + "-" * 28)
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:
is_steamdeck = False
if os.path.exists('/etc/os-release'):
with open('/etc/os-release') as f:
if 'steamdeck' in f.read().lower():
is_steamdeck = True
system_info = SystemInfo(is_steamdeck=is_steamdeck)
modlist_service = ModlistService(system_info)
categories = [
("Skyrim", "skyrim"),
("Fallout 4", "fallout4"),
("Fallout New Vegas", "falloutnv"),
("Oblivion", "oblivion"),
("Starfield", "starfield"),
("Oblivion Remastered", "oblivion_remastered"),
("Other Games", "other")
]
grouped_modlists = {}
for label, key in categories:
grouped_modlists[label] = modlist_service.list_modlists(game_type=key)
selected_modlist_info = None
while not selected_modlist_info:
print(f"\n{COLOR_PROMPT}Select a game category:{COLOR_RESET}")
category_display_map = {}
display_idx = 1
for label, _ in categories:
modlists = grouped_modlists[label]
if label == "Oblivion Remastered" or modlists:
print(f" {COLOR_SELECTION}{display_idx}.{COLOR_RESET} {label} ({len(modlists)} modlists)")
category_display_map[str(display_idx)] = label
display_idx += 1
if display_idx == 1:
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_label = category_display_map.get(game_cat_choice)
if not actual_label:
print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}")
continue
modlist_group_for_game = sorted(grouped_modlists[actual_label], key=lambda x: x.id.lower())
print(f"\n{COLOR_SUCCESS}Available Modlists for {actual_label}:{COLOR_RESET}")
for idx, m_detail in enumerate(modlist_group_for_game, 1):
if actual_label == "Other Games":
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 = {
'id': modlist_group_for_game[mod_idx].id,
'game': modlist_group_for_game[mod_idx].game,
'machine_url': getattr(modlist_group_for_game[mod_idx], 'machine_url', modlist_group_for_game[mod_idx].id)
}
self.context['modlist_source'] = 'identifier'
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 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",
no_header=True
)
if modlist_path is None:
self.logger.info("User cancelled .wabbajack file selection.")
print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}")
return None
self.context['modlist_source'] = 'path'
self.context['modlist_value'] = str(modlist_path)
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()
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:
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']}")
if 'install_dir' not in self.context:
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']}")
if 'download_dir' not in self.context:
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']}")
if 'nexus_api_key' not in self.context or not self.context.get('nexus_api_key'):
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
authenticated, method, username = auth_service.get_auth_status()
if authenticated:
if method == 'oauth':
print("\n" + "-" * 28)
print(f"{COLOR_SUCCESS}Nexus Authentication: Authorized via OAuth{COLOR_RESET}")
if username:
print(f"{COLOR_INFO}Logged in as: {username}{COLOR_RESET}")
elif method == 'api_key':
print("\n" + "-" * 28)
print(f"{COLOR_INFO}Nexus Authentication: Using API Key (Legacy){COLOR_RESET}")
api_key, oauth_info = auth_service.get_auth_for_engine()
if api_key:
self.context['nexus_api_key'] = api_key
self.context['nexus_oauth_info'] = oauth_info
else:
print(f"\n{COLOR_WARNING}Your authentication has expired or is invalid.{COLOR_RESET}")
authenticated = False
if not authenticated:
print("\n" + "-" * 28)
print(f"{COLOR_WARNING}Nexus Mods authentication is required for downloading mods.{COLOR_RESET}")
print(f"\n{COLOR_PROMPT}Would you like to authorize with Nexus now?{COLOR_RESET}")
print(f"{COLOR_INFO}This will open your browser for secure OAuth authorization.{COLOR_RESET}")
authorize = input(f"{COLOR_PROMPT}Authorize now? [Y/n]: {COLOR_RESET}").strip().lower()
if authorize in ('', 'y', 'yes'):
print(f"\n{COLOR_INFO}Starting OAuth authorization...{COLOR_RESET}")
print(f"{COLOR_WARNING}Your browser will open shortly.{COLOR_RESET}")
print(f"{COLOR_INFO}Note: You may see a security warning about a self-signed certificate.{COLOR_RESET}")
print(f"{COLOR_INFO}This is normal - click 'Advanced' and 'Proceed' to continue.{COLOR_RESET}")
def show_message(msg):
print(f"\n{COLOR_INFO}{msg}{COLOR_RESET}")
success = auth_service.authorize_oauth(show_browser_message_callback=show_message)
if success:
print(f"\n{COLOR_SUCCESS}OAuth authorization successful!{COLOR_RESET}")
_, _, username = auth_service.get_auth_status()
if username:
print(f"{COLOR_INFO}Authorized as: {username}{COLOR_RESET}")
api_key, oauth_info = auth_service.get_auth_for_engine()
if api_key:
self.context['nexus_api_key'] = api_key
self.context['nexus_oauth_info'] = oauth_info
else:
print(f"{COLOR_ERROR}Failed to retrieve auth token after authorization.{COLOR_RESET}")
return None
else:
print(f"\n{COLOR_ERROR}OAuth authorization failed.{COLOR_RESET}")
return None
else:
print(f"\n{COLOR_INFO}Authorization required to proceed. Installation cancelled.{COLOR_RESET}")
self.logger.info("User declined Nexus authorization.")
return None
self.logger.debug("Nexus authentication configured for engine.")
self._display_summary()
game_type = None
game_name = None
if self.context.get('modlist_source_type') == 'online_list':
game_name = self.context.get('modlist_game', '')
game_mapping = {
'skyrim special edition': 'skyrim',
'skyrim': 'skyrim',
'fallout 4': 'fallout4',
'fallout new vegas': 'falloutnv',
'oblivion': 'oblivion',
'starfield': 'starfield',
'oblivion remastered': 'oblivion_remastered'
}
game_type = game_mapping.get(game_name.lower())
if not game_type:
game_type = 'unknown'
elif self.context.get('modlist_source_type') == 'local_file':
wabbajack_path = self.context.get('modlist_value')
if wabbajack_path:
result = self.wabbajack_parser.parse_wabbajack_game_type(Path(wabbajack_path))
if result:
if isinstance(result, tuple):
game_type, raw_game_type = result
game_name = raw_game_type if game_type == 'unknown' else game_type
else:
game_type = result
game_name = game_type
if game_type and not self.wabbajack_parser.is_supported_game(game_type):
print("\n" + "" * 46)
print(" Game Support Notice\n")
print(f"You are about to install a modlist for: {game_name or 'Unknown'}\n")
print("Jackify does not provide post-install configuration for this game.")
print("You can still install and use the modlist, but you will need to manually set up Steam shortcuts and other steps after installation.\n")
print("Press [Enter] to continue, or [Ctrl+C] to cancel.")
print("" * 46 + "\n")
try:
input()
except KeyboardInterrupt:
print(f"{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}")
return None
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.")
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]"
self.logger.info(f"Context: {context_for_logging}")
return self.context

View File

@@ -0,0 +1,67 @@
"""Game detection methods for ModlistInstallCLI (Mixin)."""
import logging
from pathlib import Path
from typing import Optional, Dict
logger = logging.getLogger(__name__)
class ModlistOperationsGameDetectionMixin:
"""Mixin providing game type detection methods."""
def detect_game_type(self, modlist_info: Optional[Dict] = None, wabbajack_file_path: Optional[Path] = None) -> Optional[str]:
"""
Detect the game type for a modlist installation.
Args:
modlist_info: Dictionary containing modlist information (for online modlists)
wabbajack_file_path: Path to .wabbajack file (for local files)
Returns:
Jackify game type string or None if detection fails
"""
if wabbajack_file_path:
self.logger.info(f"Detecting game type from .wabbajack file: {wabbajack_file_path}")
game_type = self.wabbajack_parser.parse_wabbajack_game_type(wabbajack_file_path)
if game_type:
self.logger.info(f"Detected game type from .wabbajack file: {game_type}")
return game_type
else:
self.logger.warning(f"Could not detect game type from .wabbajack file: {wabbajack_file_path}")
return None
elif modlist_info and 'game' in modlist_info:
game_name = modlist_info['game'].lower()
self.logger.info(f"Detecting game type from modlist info: {game_name}")
game_mapping = {
'skyrim special edition': 'skyrim',
'skyrim': 'skyrim',
'fallout 4': 'fallout4',
'fallout new vegas': 'falloutnv',
'oblivion': 'oblivion',
'starfield': 'starfield',
'oblivion remastered': 'oblivion_remastered'
}
game_type = game_mapping.get(game_name)
if game_type:
self.logger.info(f"Mapped game name '{game_name}' to game type: {game_type}")
return game_type
else:
self.logger.warning(f"Unknown game name in modlist info: {game_name}")
return None
else:
self.logger.warning("No modlist info or .wabbajack file path provided for game detection")
return None
def check_game_support(self, game_type: str) -> bool:
"""
Check if a game type is supported by Jackify's post-install configuration.
Args:
game_type: Jackify game type string
Returns:
True if the game is supported, False otherwise
"""
return self.wabbajack_parser.is_supported_game(game_type)

View File

@@ -0,0 +1,99 @@
"""Nexus and engine methods for ModlistInstallCLI (Mixin)."""
import logging
import os
import re
import subprocess
from pathlib import Path
from typing import Optional
from ..handlers.ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_RESET
logger = logging.getLogger(__name__)
class ModlistOperationsNexusMixin:
"""Mixin providing Nexus API and engine methods."""
def _get_nexus_api_key(self) -> Optional[str]:
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")
"""
from .modlist_operations import get_jackify_engine_path
engine_executable = get_jackify_engine_path()
engine_dir = os.path.dirname(engine_executable)
if not os.path.exists(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 []
env = os.environ.copy()
env["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1"
command = [engine_executable, 'list-modlists', '--show-all-sizes', '--show-machine-url']
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
status_down = '[DOWN]' in line
status_nsfw = '[NSFW]' in line
clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip()
parts = clean_line.rsplit(' - ', 3)
if len(parts) != 4:
continue
modlist_name = parts[0].strip()
game_name = parts[1].strip()
sizes_str = parts[2].strip()
machine_url = parts[3].strip()
size_parts = sizes_str.split('|')
if len(size_parts) != 3:
continue
download_size = size_parts[0].strip()
install_size = size_parts[1].strip()
total_size = size_parts[2].strip()
if not modlist_name or not game_name or not machine_url:
continue
modlists.append({
'id': modlist_name,
'name': modlist_name,
'game': game_name,
'download_size': download_size,
'install_size': install_size,
'total_size': total_size,
'machine_url': machine_url,
'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 []