mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-17 11:37:45 +02:00
Sync from development - prepare for v0.3.0
This commit is contained in:
File diff suppressed because it is too large
Load Diff
546
jackify/backend/core/modlist_operations_configuration_cli.py
Normal file
546
jackify/backend/core/modlist_operations_configuration_cli.py
Normal 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}")
|
||||
170
jackify/backend/core/modlist_operations_configuration_gui.py
Normal file
170
jackify/backend/core/modlist_operations_configuration_gui.py
Normal 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
|
||||
368
jackify/backend/core/modlist_operations_discovery.py
Normal file
368
jackify/backend/core/modlist_operations_discovery.py
Normal 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
|
||||
67
jackify/backend/core/modlist_operations_game_detection.py
Normal file
67
jackify/backend/core/modlist_operations_game_detection.py
Normal 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)
|
||||
99
jackify/backend/core/modlist_operations_nexus.py
Normal file
99
jackify/backend/core/modlist_operations_nexus.py
Normal 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 []
|
||||
Reference in New Issue
Block a user