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

View File

@@ -11,10 +11,12 @@ from pathlib import Path
from ..models.modlist import ModlistContext, ModlistInfo
from ..models.configuration import SystemInfo
from .modlist_service_installation import ModlistServiceInstallationMixin
logger = logging.getLogger(__name__)
class ModlistService:
class ModlistService(ModlistServiceInstallationMixin):
"""Service for managing modlist operations."""
def __init__(self, system_info: SystemInfo):
@@ -143,268 +145,7 @@ class ModlistService:
except Exception as e:
logger.error(f"Failed to list modlists: {e}")
raise
def install_modlist(self, context: ModlistContext,
progress_callback=None,
output_callback=None) -> bool:
"""Install a modlist (ONLY installation, no configuration).
This method only runs the engine installation phase.
Configuration must be called separately after Steam setup.
Args:
context: Modlist installation context
progress_callback: Optional callback for progress updates
output_callback: Optional callback for output/logging
Returns:
True if installation successful, False otherwise
"""
logger.info(f"Installing modlist (INSTALLATION ONLY): {context.name}")
try:
# Validate context
if not self._validate_install_context(context):
logger.error("Invalid installation context")
return False
# Prepare directories
fs_handler = self._get_filesystem_handler()
fs_handler.ensure_directory(context.install_dir)
fs_handler.ensure_directory(context.download_dir)
# Use the working ModlistInstallCLI for discovery phase only
from ..core.modlist_operations import ModlistInstallCLI
# Use new SystemInfo pattern
modlist_cli = ModlistInstallCLI(self.system_info)
# Build context for ModlistInstallCLI
install_context = {
'modlist_name': context.name,
'install_dir': context.install_dir,
'download_dir': context.download_dir,
'nexus_api_key': context.nexus_api_key,
'game_type': context.game_type,
'modlist_value': context.modlist_value,
'resolution': getattr(context, 'resolution', None),
'skip_confirmation': True # Service layer should be non-interactive
}
# Set GUI mode for non-interactive operation
import os
original_gui_mode = os.environ.get('JACKIFY_GUI_MODE')
os.environ['JACKIFY_GUI_MODE'] = '1'
try:
# Run discovery phase with pre-filled context
confirmed_context = modlist_cli.run_discovery_phase(context_override=install_context)
if not confirmed_context:
logger.error("Discovery phase failed or was cancelled")
return False
# Now run ONLY the installation part (NOT configuration)
success = self._run_installation_only(
confirmed_context,
progress_callback=progress_callback,
output_callback=output_callback
)
if success:
logger.info("Modlist installation completed successfully (configuration will be done separately)")
return True
else:
logger.error("Modlist installation failed")
return False
finally:
# Restore original GUI mode
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_message = str(e)
logger.error(f"Failed to install modlist {context.name}: {error_message}")
# Check for file descriptor limit issues and attempt to handle them
from .resource_manager import handle_file_descriptor_error
try:
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, "modlist installation")
if result['auto_fix_success']:
logger.info(f"File descriptor limit increased automatically. {result['recommendation']}")
elif result['error_detected']:
logger.warning(f"File descriptor limit issue detected but automatic fix failed. {result['recommendation']}")
if result['manual_instructions']:
distro = result['manual_instructions']['distribution']
logger.info(f"Manual ulimit increase instructions available for {distro} distribution")
except Exception as resource_error:
logger.debug(f"Error checking for resource limit issues: {resource_error}")
return False
def _run_installation_only(self, context, progress_callback=None, output_callback=None) -> bool:
"""Run only the installation phase using the engine (COPIED FROM WORKING CODE)."""
import subprocess
import os
import sys
from pathlib import Path
from ..core.modlist_operations import get_jackify_engine_path
try:
# COPIED EXACTLY from working Archive_Do_Not_Write/modules/modlist_install_cli.py
# Process paths (copied from working code)
install_dir_context = context['install_dir']
if isinstance(install_dir_context, tuple):
actual_install_path = Path(install_dir_context[0])
if install_dir_context[1]:
actual_install_path.mkdir(parents=True, exist_ok=True)
else:
actual_install_path = Path(install_dir_context)
install_dir_str = str(actual_install_path)
download_dir_context = context['download_dir']
if isinstance(download_dir_context, tuple):
actual_download_path = Path(download_dir_context[0])
if download_dir_context[1]:
actual_download_path.mkdir(parents=True, exist_ok=True)
else:
actual_download_path = Path(download_dir_context)
download_dir_str = str(actual_download_path)
# CRITICAL: Re-check authentication right before launching engine
# This ensures we use current auth state, not stale cached values from context
# (e.g., if user revoked OAuth after context was created)
from ..services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
current_api_key, current_oauth_info = auth_service.get_auth_for_engine()
# Use current auth state, fallback to context values only if current check failed
api_key = current_api_key or context.get('nexus_api_key')
oauth_info = current_oauth_info or context.get('nexus_oauth_info')
# Path to the engine binary (copied from working code)
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):
if output_callback:
output_callback(f"Jackify Install Engine not found or not executable at: {engine_path}")
return False
# Build command (copied from working code)
cmd = [engine_path, 'install', '--show-file-progress']
modlist_value = 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 context.get('machineid'):
cmd += ['-m', context['machineid']]
cmd += ['-o', install_dir_str, '-d', download_dir_str]
# NOTE: API key is passed via environment variable only, not as command line argument
# Store original environment values (copied from working code)
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:
# Environment setup - prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY
if oauth_info:
os.environ['NEXUS_OAUTH_INFO'] = oauth_info
# CRITICAL: Set client_id so engine can refresh tokens with correct client_id
# Engine's RefreshToken method reads this to use our "jackify" client_id instead of hardcoded "wabbajack"
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
# Also set NEXUS_API_KEY for backward compatibility
if api_key:
os.environ['NEXUS_API_KEY'] = api_key
elif api_key:
os.environ['NEXUS_API_KEY'] = api_key
else:
# No auth available, clear any inherited values
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']
os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1"
pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd])
if output_callback:
output_callback(f"Launching Jackify Install Engine with command: {pretty_cmd}")
# Temporarily increase file descriptor limit for engine process
from jackify.backend.handlers.subprocess_utils import increase_file_descriptor_limit
success, old_limit, new_limit, message = increase_file_descriptor_limit()
if output_callback:
if success:
output_callback(f"File descriptor limit: {message}")
else:
output_callback(f"File descriptor limit warning: {message}")
# Subprocess call with cleaned environment to prevent AppImage variable inheritance
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
clean_env = get_clean_subprocess_env()
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=clean_env, cwd=engine_dir)
# Output processing (copied from working code)
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 output_callback:
output_callback(line.rstrip())
buffer = b''
elif chunk == b'\r':
line = buffer.decode('utf-8', errors='replace')
if output_callback:
output_callback(line.rstrip())
buffer = b''
if buffer:
line = buffer.decode('utf-8', errors='replace')
if output_callback:
output_callback(line.rstrip())
proc.wait()
if proc.returncode != 0:
if output_callback:
output_callback(f"Jackify Install Engine exited with code {proc.returncode}.")
return False
else:
if output_callback:
output_callback("Installation completed successfully")
return True
finally:
# Restore environment (copied from working code)
for key, original_value in original_env_values.items():
if original_value is not None:
os.environ[key] = original_value
else:
if key in os.environ:
del os.environ[key]
except Exception as e:
error_msg = f"Error running Jackify Install Engine: {e}"
logger.error(error_msg)
if output_callback:
output_callback(error_msg)
return False
def configure_modlist_post_steam(self, context: ModlistContext,
progress_callback=None,
manual_steps_callback=None,
@@ -503,7 +244,8 @@ class ModlistService:
'skip_confirmation': True, # Service layer should be non-interactive
'manual_steps_completed': True, # Manual steps were done in GUI
'appid': getattr(context, 'app_id', None), # Use updated app_id from Steam
'engine_installed': getattr(context, 'engine_installed', False) # Path manipulation flag
'engine_installed': getattr(context, 'engine_installed', False), # Path manipulation flag
'download_dir': str(context.download_dir) if getattr(context, 'download_dir', None) else None,
}
debug_callback(f"Configuration context built: {config_context}")
@@ -682,7 +424,8 @@ class ModlistService:
'resolution': getattr(context, 'resolution', None),
'skip_confirmation': True, # Service layer should be non-interactive
'manual_steps_completed': False,
'appid': getattr(context, 'app_id', None) # Fix: Include appid like other configuration paths
'appid': getattr(context, 'app_id', None), # Fix: Include appid like other configuration paths
'download_dir': str(context.download_dir) if getattr(context, 'download_dir', None) else None,
}
# DEBUG: Log what resolution we're passing