""" Modlist Service High-level service for modlist operations, orchestrating various handlers. """ import logging from typing import List, Optional, Dict, Any from pathlib import Path from ..models.modlist import ModlistContext, ModlistInfo from ..models.configuration import SystemInfo logger = logging.getLogger(__name__) class ModlistService: """Service for managing modlist operations.""" def __init__(self, system_info: SystemInfo): """Initialize the modlist service. Args: system_info: System information context """ self.system_info = system_info # Handlers will be initialized when needed self._modlist_handler = None self._wabbajack_handler = None self._filesystem_handler = None def _get_modlist_handler(self): """Lazy initialization of modlist handler.""" if self._modlist_handler is None: from ..handlers.modlist_handler import ModlistHandler from ..services.platform_detection_service import PlatformDetectionService # Initialize with proper dependencies and centralized Steam Deck detection platform_service = PlatformDetectionService.get_instance() self._modlist_handler = ModlistHandler(steamdeck=platform_service.is_steamdeck) return self._modlist_handler def _get_wabbajack_handler(self): """Lazy initialization of wabbajack handler.""" if self._wabbajack_handler is None: from ..handlers.wabbajack_handler import InstallWabbajackHandler # Initialize with proper dependencies self._wabbajack_handler = InstallWabbajackHandler() return self._wabbajack_handler def _get_filesystem_handler(self): """Lazy initialization of filesystem handler.""" if self._filesystem_handler is None: from ..handlers.filesystem_handler import FileSystemHandler self._filesystem_handler = FileSystemHandler() return self._filesystem_handler def list_modlists(self, game_type: Optional[str] = None) -> List[ModlistInfo]: """List available modlists. Args: game_type: Optional filter by game type Returns: List of available modlists """ logger.info(f"Listing modlists for game_type: {game_type}") try: # Use the working ModlistInstallCLI to get modlists from engine from ..core.modlist_operations import ModlistInstallCLI # Use new SystemInfo pattern modlist_cli = ModlistInstallCLI(self.system_info) # Get all modlists and do client-side filtering for better control raw_modlists = modlist_cli.get_all_modlists_from_engine(game_type=None) # Apply client-side filtering based on game_type if game_type: game_type_lower = game_type.lower() if game_type_lower == 'skyrim': # Include both "Skyrim" and "Skyrim Special Edition" and "Skyrim VR" raw_modlists = [m for m in raw_modlists if 'skyrim' in m.get('game', '').lower()] elif game_type_lower == 'fallout4': raw_modlists = [m for m in raw_modlists if 'fallout 4' in m.get('game', '').lower()] elif game_type_lower == 'falloutnv': raw_modlists = [m for m in raw_modlists if 'fallout new vegas' in m.get('game', '').lower()] elif game_type_lower == 'oblivion': raw_modlists = [m for m in raw_modlists if 'oblivion' in m.get('game', '').lower() and 'remastered' not in m.get('game', '').lower()] elif game_type_lower == 'starfield': raw_modlists = [m for m in raw_modlists if 'starfield' in m.get('game', '').lower()] elif game_type_lower == 'oblivion_remastered': raw_modlists = [m for m in raw_modlists if 'oblivion remastered' in m.get('game', '').lower()] elif game_type_lower == 'enderal': raw_modlists = [m for m in raw_modlists if 'enderal' in m.get('game', '').lower()] elif game_type_lower == 'other': # Exclude all main category games to show only "Other" games main_category_keywords = ['skyrim', 'fallout 4', 'fallout new vegas', 'oblivion', 'starfield', 'enderal'] def is_main_category(game_name): game_lower = game_name.lower() return any(keyword in game_lower for keyword in main_category_keywords) raw_modlists = [m for m in raw_modlists if not is_main_category(m.get('game', ''))] # Convert to ModlistInfo objects with enhanced metadata modlists = [] for m_info in raw_modlists: modlist_info = ModlistInfo( id=m_info.get('id', ''), name=m_info.get('name', m_info.get('id', '')), # Use name from enhanced data game=m_info.get('game', ''), description='', # Engine doesn't provide description yet version='', # Engine doesn't provide version yet size=f"{m_info.get('download_size', '')}|{m_info.get('install_size', '')}|{m_info.get('total_size', '')}" # Store all three sizes ) # Add enhanced metadata as additional properties if hasattr(modlist_info, '__dict__'): modlist_info.__dict__.update({ 'download_size': m_info.get('download_size', ''), 'install_size': m_info.get('install_size', ''), 'total_size': m_info.get('total_size', ''), 'machine_url': m_info.get('machine_url', ''), # Store machine URL for installation 'status_down': m_info.get('status_down', False), 'status_nsfw': m_info.get('status_nsfw', False) }) # No client-side filtering needed - engine handles it modlists.append(modlist_info) logger.info(f"Found {len(modlists)} modlists") return modlists 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, completion_callback=None) -> bool: """Configure a modlist after Steam setup is complete. This method should only be called AFTER: 1. Modlist installation is complete 2. Steam shortcut has been created 3. Steam has been restarted 4. Manual Proton steps have been completed Args: context: Modlist context with updated app_id progress_callback: Optional callback for progress updates manual_steps_callback: Called when manual steps needed completion_callback: Called when configuration is complete Returns: True if configuration successful, False otherwise """ logger.info(f"Configuring modlist after Steam setup: {context.name}") # Check if debug mode is enabled and create debug callback from ..handlers.config_handler import ConfigHandler config_handler = ConfigHandler() debug_mode = config_handler.get('debug_mode', False) def debug_callback(message): """Send debug message to GUI if debug mode is enabled""" if debug_mode and progress_callback: progress_callback(f"[DEBUG] {message}") debug_callback(f"Starting configuration for {context.name}") debug_callback(f"Debug mode enabled: {debug_mode}") debug_callback(f"Install directory: {context.install_dir}") debug_callback(f"Resolution: {getattr(context, 'resolution', 'Not set')}") debug_callback(f"App ID: {getattr(context, 'app_id', 'Not set')}") # Set up a custom logging handler to capture backend DEBUG messages gui_log_handler = None if debug_mode and progress_callback: import logging class GuiLogHandler(logging.Handler): def __init__(self, callback): super().__init__() self.callback = callback self.setLevel(logging.DEBUG) def emit(self, record): try: msg = self.format(record) if record.levelno == logging.DEBUG: self.callback(f"[DEBUG] {msg}") elif record.levelno >= logging.WARNING: self.callback(f"[{record.levelname}] {msg}") except Exception: pass gui_log_handler = GuiLogHandler(progress_callback) gui_log_handler.setFormatter(logging.Formatter('%(message)s')) # Add the GUI handler to key backend loggers backend_logger_names = [ 'jackify.backend.handlers.menu_handler', 'jackify.backend.handlers.modlist_handler', 'jackify.backend.handlers.install_wabbajack_handler', 'jackify.backend.handlers.wabbajack_handler', 'jackify.backend.handlers.shortcut_handler', 'jackify.backend.handlers.protontricks_handler', 'jackify.backend.handlers.validation_handler', 'jackify.backend.handlers.resolution_handler' ] for logger_name in backend_logger_names: backend_logger = logging.getLogger(logger_name) backend_logger.addHandler(gui_log_handler) backend_logger.setLevel(logging.DEBUG) debug_callback("GUI logging handler installed for backend services") try: # COPY THE WORKING LOGIC: Use menu handler for configuration only from ..handlers.menu_handler import ModlistMenuHandler # Initialize handlers (same as working code) modlist_menu = ModlistMenuHandler(config_handler) # Build configuration context (copied from working code) config_context = { 'name': context.name, 'path': str(context.install_dir), 'mo2_exe_path': str(context.install_dir / 'ModOrganizer.exe'), 'resolution': getattr(context, 'resolution', None), '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 } debug_callback(f"Configuration context built: {config_context}") debug_callback("Setting up GUI mode and stdout redirection") # Set GUI mode for proper callback handling import os original_gui_mode = os.environ.get('JACKIFY_GUI_MODE') original_stdout = None try: # Force GUI mode to prevent input prompts os.environ['JACKIFY_GUI_MODE'] = '1' # CRITICAL FIX: Redirect print output to capture progress messages import sys from io import StringIO # Create a custom stdout that forwards to GUI class GuiRedirectStdout: def __init__(self, callback): self.callback = callback self.buffer = "" def write(self, text): if self.callback and text.strip(): # Convert ANSI codes to HTML for colored GUI output try: from ...frontends.gui.utils import ansi_to_html # Clean up carriage returns but preserve ANSI colors clean_text = text.replace('\r', '').strip() if clean_text and clean_text != "Current Task: ": # Convert ANSI to HTML for colored display html_text = ansi_to_html(clean_text) self.callback(html_text) except ImportError: # Fallback: strip ANSI codes if ansi_to_html not available import re clean_text = re.sub(r'\x1b\[[0-9;]*[mK]', '', text) clean_text = clean_text.replace('\r', '').strip() if clean_text and clean_text != "Current Task: ": self.callback(clean_text) return len(text) def flush(self): pass # Redirect stdout to capture print statements if progress_callback: original_stdout = sys.stdout sys.stdout = GuiRedirectStdout(progress_callback) # Call the working configuration-only method debug_callback("Calling run_modlist_configuration_phase") success = modlist_menu.run_modlist_configuration_phase(config_context) debug_callback(f"Configuration phase result: {success}") # Restore stdout before ENB detection and completion callback if original_stdout: sys.stdout = original_stdout original_stdout = None # Configure ENB for Linux compatibility (non-blocking) # Do this BEFORE completion callback so we can pass detection status enb_detected = False try: from ..handlers.enb_handler import ENBHandler enb_handler = ENBHandler() enb_success, enb_message, enb_detected = enb_handler.configure_enb_for_linux(context.install_dir) if enb_message: if enb_success: logger.info(enb_message) if progress_callback: progress_callback(enb_message) else: logger.warning(enb_message) # Non-blocking: continue workflow even if ENB config fails except Exception as e: logger.warning(f"ENB configuration skipped due to error: {e}") # Continue workflow - ENB config is optional # Store ENB detection status in context for GUI to use context.enb_detected = enb_detected if completion_callback: if success: debug_callback("Configuration completed successfully, calling completion callback") # Pass ENB detection status through callback completion_callback(True, "Configuration completed successfully!", context.name, enb_detected) else: debug_callback("Configuration failed, calling completion callback with failure") completion_callback(False, "Configuration failed", context.name, False) return success finally: # Always restore stdout and environment if original_stdout: sys.stdout = original_stdout if original_gui_mode is not None: os.environ['JACKIFY_GUI_MODE'] = original_gui_mode else: os.environ.pop('JACKIFY_GUI_MODE', None) # Remove GUI log handler to avoid memory leaks if gui_log_handler: for logger_name in [ 'jackify.backend.handlers.menu_handler', 'jackify.backend.handlers.modlist_handler', 'jackify.backend.handlers.install_wabbajack_handler', 'jackify.backend.handlers.wabbajack_handler', 'jackify.backend.handlers.shortcut_handler', 'jackify.backend.handlers.protontricks_handler', 'jackify.backend.handlers.validation_handler', 'jackify.backend.handlers.resolution_handler' ]: backend_logger = logging.getLogger(logger_name) backend_logger.removeHandler(gui_log_handler) except Exception as e: logger.error(f"Failed to configure modlist {context.name}: {e}") if completion_callback: completion_callback(False, f"Configuration failed: {e}", context.name, False) # Clean up GUI log handler on exception if gui_log_handler: for logger_name in [ 'jackify.backend.handlers.menu_handler', 'jackify.backend.handlers.modlist_handler', 'jackify.backend.handlers.install_wabbajack_handler', 'jackify.backend.handlers.wabbajack_handler', 'jackify.backend.handlers.shortcut_handler', 'jackify.backend.handlers.protontricks_handler', 'jackify.backend.handlers.validation_handler', 'jackify.backend.handlers.resolution_handler' ]: backend_logger = logging.getLogger(logger_name) backend_logger.removeHandler(gui_log_handler) return False def configure_modlist(self, context: ModlistContext, progress_callback=None, manual_steps_callback=None, completion_callback=None, output_callback=None) -> bool: """Configure a modlist after installation. Args: context: Modlist context progress_callback: Optional callback for progress updates manual_steps_callback: Optional callback for manual steps completion_callback: Optional callback for completion output_callback: Optional callback for output/logging Returns: True if configuration successful, False otherwise """ logger.info(f"Configuring modlist: {context.name}") try: # Use the working ModlistMenuHandler for configuration from ..handlers.menu_handler import ModlistMenuHandler from ..handlers.config_handler import ConfigHandler config_handler = ConfigHandler() modlist_menu = ModlistMenuHandler(config_handler) # Build configuration context config_context = { 'name': context.name, 'path': str(context.install_dir), 'mo2_exe_path': str(context.install_dir / 'ModOrganizer.exe'), '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 } # DEBUG: Log what resolution we're passing logger.info(f"DEBUG: config_context resolution = {config_context['resolution']}") logger.info(f"DEBUG: context.resolution = {getattr(context, 'resolution', 'NOT_SET')}") # Run the complete configuration phase success = modlist_menu.run_modlist_configuration_phase(config_context) if success: logger.info("Modlist configuration completed successfully") if completion_callback: completion_callback(True, "Configuration completed successfully", context.name, False) else: logger.warning("Modlist configuration had issues") if completion_callback: completion_callback(False, "Configuration failed", context.name, False) return success except Exception as e: logger.error(f"Failed to configure modlist {context.name}: {e}") return False def _validate_install_context(self, context: ModlistContext) -> bool: """Validate that the installation context is complete and valid. Args: context: The context to validate Returns: True if valid, False otherwise """ if not context.name: logger.error("Modlist name is required") return False if not context.install_dir: logger.error("Install directory is required") return False if not context.download_dir: logger.error("Download directory is required") return False if not context.nexus_api_key: logger.error("Nexus API key is required") return False if not context.game_type: logger.error("Game type is required") return False return True