Initial public release v0.1.0 - Linux Wabbajack Modlist Application

Jackify provides native Linux support for Wabbajack modlist installation
   and management with automated Steam integration and Proton configuration.

   Key Features:
   - Almost Native Linux implementation (texconv.exe run via proton)
   - Automated Steam shortcut creation and Proton prefix management
   - Both CLI and GUI interfaces, with Steam Deck optimization

   Supported Games:
   - Skyrim Special Edition
   - Fallout 4
   - Fallout New Vegas
   - Oblivion, Starfield, Enderal, and diverse other games

   Technical Architecture:
   - Clean separation between frontend and backend services
   - Powered by jackify-engine 0.3.x for Wabbajack-matching modlist installation
This commit is contained in:
Omni
2025-09-05 20:46:24 +01:00
commit cd591c14e3
445 changed files with 40398 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
"""
Backend Services
High-level service classes that orchestrate handlers.
"""

View File

@@ -0,0 +1,271 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
API Key Service Module
Centralized service for managing Nexus API keys across CLI and GUI frontends
"""
import logging
from typing import Optional, Tuple
from ..handlers.config_handler import ConfigHandler
# Initialize logger
logger = logging.getLogger(__name__)
class APIKeyService:
"""
Centralized service for managing Nexus API keys
Handles saving, loading, and validation of API keys
"""
def __init__(self):
"""Initialize the API key service"""
self.config_handler = ConfigHandler()
logger.debug("APIKeyService initialized")
def save_api_key(self, api_key: str) -> bool:
"""
Save an API key to configuration
Args:
api_key (str): The API key to save
Returns:
bool: True if saved successfully, False otherwise
"""
try:
# Validate API key format (basic check)
if not self._validate_api_key_format(api_key):
logger.warning("Invalid API key format provided")
return False
# Check if we can write to config directory
import os
config_dir = os.path.dirname(self.config_handler.config_file)
if not os.path.exists(config_dir):
try:
os.makedirs(config_dir, exist_ok=True)
logger.debug(f"Created config directory: {config_dir}")
except PermissionError:
logger.error(f"Permission denied creating config directory: {config_dir}")
return False
except Exception as dir_error:
logger.error(f"Error creating config directory: {dir_error}")
return False
# Check write permissions
if not os.access(config_dir, os.W_OK):
logger.error(f"No write permission for config directory: {config_dir}")
return False
success = self.config_handler.save_api_key(api_key)
if success:
logger.info("API key saved successfully")
# Verify the save worked by reading it back
saved_key = self.config_handler.get_api_key()
if saved_key != api_key:
logger.error("API key save verification failed - key mismatch")
return False
else:
logger.error("Failed to save API key via config handler")
return success
except Exception as e:
logger.error(f"Error in save_api_key: {e}")
return False
def get_saved_api_key(self) -> Optional[str]:
"""
Retrieve the saved API key from configuration
Returns:
str: The decoded API key or None if not saved
"""
try:
api_key = self.config_handler.get_api_key()
if api_key:
logger.debug("Retrieved saved API key")
else:
logger.debug("No saved API key found")
return api_key
except Exception as e:
logger.error(f"Error retrieving API key: {e}")
return None
def has_saved_api_key(self) -> bool:
"""
Check if an API key is saved in configuration
Returns:
bool: True if API key exists, False otherwise
"""
try:
return self.config_handler.has_saved_api_key()
except Exception as e:
logger.error(f"Error checking for saved API key: {e}")
return False
def clear_saved_api_key(self) -> bool:
"""
Clear the saved API key from configuration
Returns:
bool: True if cleared successfully, False otherwise
"""
try:
success = self.config_handler.clear_api_key()
if success:
logger.info("API key cleared successfully")
else:
logger.error("Failed to clear API key")
return success
except Exception as e:
logger.error(f"Error clearing API key: {e}")
return False
def get_api_key_for_session(self, provided_key: Optional[str] = None,
use_saved: bool = True) -> Tuple[Optional[str], str]:
"""
Get the API key to use for a session, with priority logic
Args:
provided_key (str, optional): API key provided by user for this session
use_saved (bool): Whether to use saved API key if no key provided
Returns:
tuple: (api_key, source) where source is 'provided', 'saved', or 'none'
"""
try:
# Priority 1: Use provided key if given
if provided_key and self._validate_api_key_format(provided_key):
logger.debug("Using provided API key for session")
return provided_key, 'provided'
# Priority 2: Use saved key if enabled and available
if use_saved and self.has_saved_api_key():
saved_key = self.get_saved_api_key()
if saved_key:
logger.debug("Using saved API key for session")
return saved_key, 'saved'
# No valid API key available
logger.debug("No valid API key available for session")
return None, 'none'
except Exception as e:
logger.error(f"Error getting API key for session: {e}")
return None, 'none'
def _validate_api_key_format(self, api_key: str) -> bool:
"""
Validate basic API key format
Args:
api_key (str): API key to validate
Returns:
bool: True if format appears valid, False otherwise
"""
if not api_key or not isinstance(api_key, str):
return False
# Basic validation: should be alphanumeric string of reasonable length
# Nexus API keys are typically 32+ characters, alphanumeric with some special chars
api_key = api_key.strip()
if len(api_key) < 10: # Too short to be valid
return False
if len(api_key) > 200: # Unreasonably long
return False
# Should contain some alphanumeric characters
if not any(c.isalnum() for c in api_key):
return False
return True
def get_api_key_display(self, api_key: str, mask_after_chars: int = 4) -> str:
"""
Get a masked version of the API key for display purposes
Args:
api_key (str): The API key to mask
mask_after_chars (int): Number of characters to show before masking
Returns:
str: Masked API key for display
"""
if not api_key:
return ""
if len(api_key) <= mask_after_chars:
return "*" * len(api_key)
visible_part = api_key[:mask_after_chars]
masked_part = "*" * (len(api_key) - mask_after_chars)
return visible_part + masked_part
def validate_api_key_works(self, api_key: str) -> Tuple[bool, str]:
"""
Validate that an API key actually works with Nexus API
Tests the key against the Nexus Mods validation endpoint
Args:
api_key (str): API key to validate
Returns:
tuple: (is_valid, message)
"""
# First check format
if not self._validate_api_key_format(api_key):
return False, "API key format is invalid"
try:
import requests
import time
# Nexus API validation endpoint
url = "https://api.nexusmods.com/v1/users/validate.json"
headers = {
'apikey': api_key,
'User-Agent': 'Jackify/1.0' # Required by Nexus API
}
# Set a reasonable timeout
response = requests.get(url, headers=headers, timeout=10)
if response.status_code == 200:
# API key is valid
try:
data = response.json()
username = data.get('name', 'Unknown')
# Don't log the actual API key - use masking
masked_key = self.get_api_key_display(api_key)
logger.info(f"API key validation successful for user: {username} (key: {masked_key})")
return True, f"API key valid for user: {username}"
except Exception as json_error:
logger.warning(f"API key valid but couldn't parse user info: {json_error}")
return True, "API key is valid"
elif response.status_code == 401:
# Invalid API key
logger.warning("API key validation failed: Invalid key")
return False, "Invalid API key"
elif response.status_code == 429:
# Rate limited
logger.warning("API key validation rate limited")
return False, "Rate limited - try again later"
else:
# Other error
logger.warning(f"API key validation failed with status {response.status_code}")
return False, f"Validation failed (HTTP {response.status_code})"
except requests.exceptions.Timeout:
logger.warning("API key validation timed out")
return False, "Validation timed out - check connection"
except requests.exceptions.ConnectionError:
logger.warning("API key validation connection error")
return False, "Connection error - check internet"
except Exception as e:
logger.error(f"API key validation error: {e}")
return False, f"Validation error: {str(e)}"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,690 @@
"""
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
# Initialize with proper dependencies
self._modlist_handler = ModlistHandler()
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)
api_key = context['nexus_api_key']
# 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']
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]
# Check for debug mode and add --debug flag
from ..handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False)
if debug_mode:
cmd.append('--debug')
logger.debug("DEBUG: Added --debug flag to jackify-engine command")
# 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'),
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
}
try:
# Environment setup (copied from working code)
if api_key:
os.environ['NEXUS_API_KEY'] = api_key
elif 'NEXUS_API_KEY' in os.environ:
del os.environ['NEXUS_API_KEY']
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 (copied from working code)
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=None, 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 calling completion callback
if original_stdout:
sys.stdout = original_stdout
original_stdout = None
if completion_callback:
if success:
debug_callback("Configuration completed successfully, calling completion callback")
completion_callback(True, "Configuration completed successfully!", context.name)
else:
debug_callback("Configuration failed, calling completion callback with failure")
completion_callback(False, "Configuration failed", context.name)
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)
# 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
}
# 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)
else:
logger.warning("Modlist configuration had issues")
if completion_callback:
completion_callback(False, "Configuration failed", context.name)
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

View File

@@ -0,0 +1,406 @@
#!/usr/bin/env python3
"""
Native Steam Shortcut and Proton Management Service
This service replaces STL entirely with native Python VDF manipulation.
Handles both shortcut creation and Proton version setting reliably.
"""
import os
import sys
import time
import logging
import hashlib
import vdf
from pathlib import Path
from typing import Optional, Tuple, Dict, Any, List
logger = logging.getLogger(__name__)
class NativeSteamService:
"""
Native Steam shortcut and Proton management service.
This completely replaces STL with reliable VDF manipulation that:
1. Creates shortcuts with proper VDF structure
2. Sets Proton versions in the correct config files
3. Never corrupts existing shortcuts
"""
def __init__(self):
self.steam_path = Path.home() / ".steam" / "steam"
self.userdata_path = self.steam_path / "userdata"
self.user_id = None
self.user_config_path = None
def find_steam_user(self) -> bool:
"""Find the active Steam user directory"""
try:
if not self.userdata_path.exists():
logger.error("Steam userdata directory not found")
return False
# Find the first user directory (usually there's only one)
user_dirs = [d for d in self.userdata_path.iterdir() if d.is_dir() and d.name.isdigit()]
if not user_dirs:
logger.error("No Steam user directories found")
return False
# Use the first user directory
user_dir = user_dirs[0]
self.user_id = user_dir.name
self.user_config_path = user_dir / "config"
logger.info(f"Found Steam user: {self.user_id}")
logger.info(f"User config path: {self.user_config_path}")
return True
except Exception as e:
logger.error(f"Error finding Steam user: {e}")
return False
def get_shortcuts_vdf_path(self) -> Optional[Path]:
"""Get the path to shortcuts.vdf"""
if not self.user_config_path:
if not self.find_steam_user():
return None
shortcuts_path = self.user_config_path / "shortcuts.vdf"
return shortcuts_path if shortcuts_path.exists() else shortcuts_path
def get_localconfig_vdf_path(self) -> Optional[Path]:
"""Get the path to localconfig.vdf"""
if not self.user_config_path:
if not self.find_steam_user():
return None
return self.user_config_path / "localconfig.vdf"
def read_shortcuts_vdf(self) -> Dict[str, Any]:
"""Read the shortcuts.vdf file safely"""
shortcuts_path = self.get_shortcuts_vdf_path()
if not shortcuts_path:
return {'shortcuts': {}}
try:
if shortcuts_path.exists():
with open(shortcuts_path, 'rb') as f:
data = vdf.binary_load(f)
return data
else:
logger.info("shortcuts.vdf does not exist, will create new one")
return {'shortcuts': {}}
except Exception as e:
logger.error(f"Error reading shortcuts.vdf: {e}")
return {'shortcuts': {}}
def write_shortcuts_vdf(self, data: Dict[str, Any]) -> bool:
"""Write the shortcuts.vdf file safely"""
shortcuts_path = self.get_shortcuts_vdf_path()
if not shortcuts_path:
return False
try:
# Create backup first
if shortcuts_path.exists():
backup_path = shortcuts_path.with_suffix(f".vdf.backup_{int(time.time())}")
import shutil
shutil.copy2(shortcuts_path, backup_path)
logger.info(f"Created backup: {backup_path}")
# Ensure parent directory exists
shortcuts_path.parent.mkdir(parents=True, exist_ok=True)
# Write the VDF file
with open(shortcuts_path, 'wb') as f:
vdf.binary_dump(data, f)
logger.info("Successfully wrote shortcuts.vdf")
return True
except Exception as e:
logger.error(f"Error writing shortcuts.vdf: {e}")
return False
def generate_app_id(self, app_name: str, exe_path: str) -> Tuple[int, int]:
"""
Generate AppID using STL's exact algorithm (MD5-based).
This matches STL's generateShortcutVDFAppId and generateSteamShortID functions:
1. Combine AppName + ExePath
2. Generate MD5 hash, take first 8 characters
3. Convert to decimal, make negative, ensure < 1 billion
4. Convert signed to unsigned for CompatToolMapping
Returns:
(signed_app_id, unsigned_app_id) - Both the signed and unsigned versions
"""
# STL's algorithm: MD5 of app_name + exe_path
input_string = f"{app_name}{exe_path}"
# Generate MD5 hash and take first 8 characters
md5_hash = hashlib.md5(input_string.encode('utf-8')).hexdigest()
seed = md5_hash[:8]
# Convert hex to decimal and make it negative with modulo 1 billion
seed_decimal = int(seed, 16)
signed_app_id = -(seed_decimal % 1000000000)
# Convert to unsigned using steam-conductor/trentondyck method (signed_app_id + 2^32)
unsigned_app_id = signed_app_id + 2**32
logger.info(f"Generated AppID using STL algorithm for '{app_name}' + '{exe_path}': {signed_app_id} (unsigned: {unsigned_app_id})")
return signed_app_id, unsigned_app_id
def create_shortcut(self, app_name: str, exe_path: str, start_dir: str = None,
launch_options: str = "%command%", tags: List[str] = None) -> Tuple[bool, Optional[int]]:
"""
Create a Steam shortcut using direct VDF manipulation.
Args:
app_name: The shortcut name
exe_path: Path to the executable
start_dir: Start directory (defaults to exe directory)
launch_options: Launch options (defaults to "%command%")
tags: List of tags to apply
Returns:
(success, unsigned_app_id) - Success status and the AppID
"""
if not start_dir:
start_dir = str(Path(exe_path).parent)
if not tags:
tags = ["Jackify"]
logger.info(f"Creating shortcut '{app_name}' for '{exe_path}'")
try:
# Read current shortcuts
data = self.read_shortcuts_vdf()
shortcuts = data.get('shortcuts', {})
# Generate AppID
signed_app_id, unsigned_app_id = self.generate_app_id(app_name, exe_path)
# Find next available index
indices = [int(k) for k in shortcuts.keys() if k.isdigit()]
next_index = max(indices, default=-1) + 1
# Get icon path from SteamIcons directory if available
icon_path = ''
steamicons_dir = Path(exe_path).parent / "SteamIcons"
if steamicons_dir.is_dir():
grid_tall_icon = steamicons_dir / "grid-tall.png"
if grid_tall_icon.exists():
icon_path = str(grid_tall_icon)
logger.info(f"Using icon from SteamIcons: {icon_path}")
else:
# Look for any PNG file
png_files = list(steamicons_dir.glob("*.png"))
if png_files:
icon_path = str(png_files[0])
logger.info(f"Using fallback icon: {icon_path}")
# Create the shortcut entry with proper structure
shortcut_entry = {
'appid': signed_app_id, # Use signed AppID in shortcuts.vdf
'AppName': app_name,
'Exe': f'"{exe_path}"',
'StartDir': f'"{start_dir}"',
'icon': icon_path,
'ShortcutPath': '',
'LaunchOptions': launch_options,
'IsHidden': 0,
'AllowDesktopConfig': 1,
'AllowOverlay': 1,
'OpenVR': 0,
'Devkit': 0,
'DevkitGameID': '',
'DevkitOverrideAppID': 0,
'LastPlayTime': 0,
'IsInstalled': 1, # Mark as installed so it appears in "Installed locally"
'FlatpakAppID': '',
'tags': {}
}
# Add tags
for i, tag in enumerate(tags):
shortcut_entry['tags'][str(i)] = tag
# Add to shortcuts
shortcuts[str(next_index)] = shortcut_entry
data['shortcuts'] = shortcuts
# Write back to file
if self.write_shortcuts_vdf(data):
logger.info(f"✅ Shortcut created successfully at index {next_index}")
return True, unsigned_app_id
else:
logger.error("❌ Failed to write shortcut to VDF")
return False, None
except Exception as e:
logger.error(f"❌ Error creating shortcut: {e}")
return False, None
def set_proton_version(self, app_id: int, proton_version: str = "proton_experimental") -> bool:
"""
Set the Proton version for a specific app using ONLY config.vdf like steam-conductor does.
Args:
app_id: The unsigned AppID
proton_version: The Proton version to set
Returns:
True if successful
"""
logger.info(f"Setting Proton version '{proton_version}' for AppID {app_id} using STL-compatible format")
try:
# Step 1: Write to the main config.vdf for CompatToolMapping
config_path = self.steam_path / "config" / "config.vdf"
if not config_path.exists():
logger.error(f"Steam config.vdf not found at: {config_path}")
return False
# Create backup first
backup_path = config_path.with_suffix(f".vdf.backup_{int(time.time())}")
import shutil
shutil.copy2(config_path, backup_path)
logger.info(f"Created backup: {backup_path}")
# Read the file as text to avoid VDF library formatting issues
with open(config_path, 'r', encoding='utf-8', errors='ignore') as f:
config_text = f.read()
# Find the CompatToolMapping section
compat_start = config_text.find('"CompatToolMapping"')
if compat_start == -1:
logger.error("CompatToolMapping section not found in config.vdf")
return False
# Find the closing brace for CompatToolMapping
# Look for the opening brace after CompatToolMapping
brace_start = config_text.find('{', compat_start)
if brace_start == -1:
logger.error("CompatToolMapping opening brace not found")
return False
# Count braces to find the matching closing brace
brace_count = 1
pos = brace_start + 1
compat_end = -1
while pos < len(config_text) and brace_count > 0:
if config_text[pos] == '{':
brace_count += 1
elif config_text[pos] == '}':
brace_count -= 1
if brace_count == 0:
compat_end = pos
break
pos += 1
if compat_end == -1:
logger.error("CompatToolMapping closing brace not found")
return False
# Check if this AppID already exists
app_id_pattern = f'"{app_id}"'
app_id_exists = app_id_pattern in config_text[compat_start:compat_end]
if app_id_exists:
logger.info(f"AppID {app_id} already exists in CompatToolMapping, will be overwritten")
# Remove the existing entry by finding and removing the entire block
# This is complex, so for now just add at the end
# Create the new entry in STL's exact format (tabs between key and value)
new_entry = f'\t\t\t\t\t"{app_id}"\n\t\t\t\t\t{{\n\t\t\t\t\t\t"name"\t\t"{proton_version}"\n\t\t\t\t\t\t"config"\t\t""\n\t\t\t\t\t\t"priority"\t\t"250"\n\t\t\t\t\t}}\n'
# Insert the new entry just before the closing brace of CompatToolMapping
new_config_text = config_text[:compat_end] + new_entry + config_text[compat_end:]
# Write back the modified text
with open(config_path, 'w', encoding='utf-8') as f:
f.write(new_config_text)
logger.info(f"✅ Successfully set Proton version '{proton_version}' for AppID {app_id} using config.vdf only (steam-conductor method)")
return True
except Exception as e:
logger.error(f"❌ Error setting Proton version: {e}")
return False
def create_shortcut_with_proton(self, app_name: str, exe_path: str, start_dir: str = None,
launch_options: str = "%command%", tags: List[str] = None,
proton_version: str = "proton_experimental") -> Tuple[bool, Optional[int]]:
"""
Complete workflow: Create shortcut and set Proton version.
This is the main method that replaces STL entirely.
Returns:
(success, app_id) - Success status and the AppID
"""
logger.info(f"Creating shortcut with Proton: '{app_name}' -> '{proton_version}'")
# Step 1: Create the shortcut
success, app_id = self.create_shortcut(app_name, exe_path, start_dir, launch_options, tags)
if not success:
logger.error("Failed to create shortcut")
return False, None
# Step 2: Set the Proton version
if not self.set_proton_version(app_id, proton_version):
logger.error("Failed to set Proton version (shortcut still created)")
return False, app_id # Shortcut exists but Proton setting failed
logger.info(f"✅ Complete workflow successful: '{app_name}' with '{proton_version}'")
return True, app_id
def list_shortcuts(self) -> Dict[str, str]:
"""List all existing shortcuts (for debugging)"""
shortcuts = self.read_shortcuts_vdf().get('shortcuts', {})
shortcut_list = {}
for index, shortcut in shortcuts.items():
app_name = shortcut.get('AppName', 'Unknown')
shortcut_list[index] = app_name
return shortcut_list
def remove_shortcut(self, app_name: str) -> bool:
"""Remove a shortcut by name"""
try:
data = self.read_shortcuts_vdf()
shortcuts = data.get('shortcuts', {})
# Find shortcut by name
to_remove = None
for index, shortcut in shortcuts.items():
if shortcut.get('AppName') == app_name:
to_remove = index
break
if to_remove is None:
logger.warning(f"Shortcut '{app_name}' not found")
return False
# Remove the shortcut
del shortcuts[to_remove]
data['shortcuts'] = shortcuts
# Write back
if self.write_shortcuts_vdf(data):
logger.info(f"✅ Removed shortcut '{app_name}'")
return True
else:
logger.error("❌ Failed to write updated shortcuts")
return False
except Exception as e:
logger.error(f"❌ Error removing shortcut: {e}")
return False

View File

@@ -0,0 +1,220 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Protontricks Detection Service Module
Centralized service for detecting and managing protontricks installation across CLI and GUI frontends
"""
import logging
import shutil
import subprocess
from typing import Optional, Tuple
from ..handlers.protontricks_handler import ProtontricksHandler
from ..handlers.config_handler import ConfigHandler
# Initialize logger
logger = logging.getLogger(__name__)
class ProtontricksDetectionService:
"""
Centralized service for detecting and managing protontricks installation
Handles detection, validation, and installation guidance for both CLI and GUI
"""
def __init__(self, steamdeck: bool = False):
"""
Initialize the protontricks detection service
Args:
steamdeck (bool): Whether running on Steam Deck
"""
self.steamdeck = steamdeck
self.config_handler = ConfigHandler()
self._protontricks_handler = None
self._last_detection_result = None
self._cached_detection_valid = False
logger.debug(f"ProtontricksDetectionService initialized (steamdeck={steamdeck})")
def _get_protontricks_handler(self) -> ProtontricksHandler:
"""Get or create ProtontricksHandler instance"""
if self._protontricks_handler is None:
self._protontricks_handler = ProtontricksHandler(steamdeck=self.steamdeck)
return self._protontricks_handler
def detect_protontricks(self, use_cache: bool = True) -> Tuple[bool, str, str]:
"""
Detect if protontricks is installed and get installation details
Args:
use_cache (bool): Whether to use cached detection result
Returns:
Tuple[bool, str, str]: (is_installed, installation_type, details_message)
- is_installed: True if protontricks is available
- installation_type: 'native', 'flatpak', or 'none'
- details_message: Human-readable status message
"""
if use_cache and self._cached_detection_valid and self._last_detection_result:
logger.debug("Using cached protontricks detection result")
return self._last_detection_result
logger.info("Detecting protontricks installation...")
handler = self._get_protontricks_handler()
# Reset handler state for fresh detection
handler.which_protontricks = None
handler.protontricks_path = None
handler.protontricks_version = None
# Perform detection without user prompts
is_installed = self._detect_without_prompts(handler)
# Determine installation type and create message
if is_installed:
installation_type = handler.which_protontricks or 'unknown'
if installation_type == 'native':
details_message = f"Native protontricks found at {handler.protontricks_path}"
elif installation_type == 'flatpak':
details_message = "Flatpak protontricks is installed"
else:
details_message = "Protontricks is installed (unknown type)"
else:
installation_type = 'none'
details_message = "Protontricks not found - required for Jackify functionality"
# Cache the result
self._last_detection_result = (is_installed, installation_type, details_message)
self._cached_detection_valid = True
logger.info(f"Protontricks detection complete: {details_message}")
return self._last_detection_result
def _detect_without_prompts(self, handler: ProtontricksHandler) -> bool:
"""
Detect protontricks without user prompts or installation attempts
Args:
handler (ProtontricksHandler): Handler instance to use
Returns:
bool: True if protontricks is found
"""
import shutil
# Check if protontricks exists as a command
protontricks_path_which = shutil.which("protontricks")
if protontricks_path_which:
# Check if it's a flatpak wrapper
try:
with open(protontricks_path_which, 'r') as f:
content = f.read()
if "flatpak run" in content:
logger.debug(f"Detected Protontricks is a Flatpak wrapper at {protontricks_path_which}")
handler.which_protontricks = 'flatpak'
# Continue to check flatpak list just to be sure
else:
logger.info(f"Native Protontricks found at {protontricks_path_which}")
handler.which_protontricks = 'native'
handler.protontricks_path = protontricks_path_which
return True
except Exception as e:
logger.error(f"Error reading protontricks executable: {e}")
# Check if flatpak protontricks is installed
try:
env = handler._get_clean_subprocess_env()
result = subprocess.run(
["flatpak", "list"],
capture_output=True,
text=True,
check=True,
env=env
)
if "com.github.Matoking.protontricks" in result.stdout:
logger.info("Flatpak Protontricks is installed")
handler.which_protontricks = 'flatpak'
return True
except FileNotFoundError:
logger.warning("'flatpak' command not found. Cannot check for Flatpak Protontricks.")
except subprocess.CalledProcessError as e:
logger.warning(f"Error checking flatpak list: {e}")
except Exception as e:
logger.error(f"Unexpected error checking flatpak: {e}")
return False
def install_flatpak_protontricks(self) -> Tuple[bool, str]:
"""
Install protontricks via Flatpak
Returns:
Tuple[bool, str]: (success, message)
"""
logger.info("Attempting to install Flatpak Protontricks...")
try:
handler = self._get_protontricks_handler()
# Check if flatpak is available
if not shutil.which("flatpak"):
error_msg = "Flatpak not found. Please install Flatpak first."
logger.error(error_msg)
return False, error_msg
# Install command
install_cmd = ["flatpak", "install", "-u", "-y", "--noninteractive", "flathub", "com.github.Matoking.protontricks"]
# Use clean environment
env = handler._get_clean_subprocess_env()
# Run installation
process = subprocess.run(install_cmd, check=True, text=True, env=env, capture_output=True)
# Clear cache to force re-detection
self._cached_detection_valid = False
success_msg = "Flatpak Protontricks installed successfully."
logger.info(success_msg)
return True, success_msg
except FileNotFoundError:
error_msg = "Flatpak command not found. Please install Flatpak first."
logger.error(error_msg)
return False, error_msg
except subprocess.CalledProcessError as e:
error_msg = f"Flatpak installation failed: {e}"
logger.error(error_msg)
return False, error_msg
except Exception as e:
error_msg = f"Unexpected error during Flatpak installation: {e}"
logger.error(error_msg)
return False, error_msg
def get_installation_guidance(self) -> str:
"""
Get guidance message for installing protontricks natively
Returns:
str: Installation guidance message
"""
return """To install protontricks natively, use your distribution's package manager:
• Arch Linux: sudo pacman -S protontricks
• Ubuntu/Debian: sudo apt install protontricks
• Fedora: sudo dnf install protontricks
• OpenSUSE: sudo zypper install protontricks
• Gentoo: sudo emerge protontricks
Alternatively, you can install via Flatpak:
flatpak install flathub com.github.Matoking.protontricks
After installation, click "Re-detect" to continue."""
def clear_cache(self):
"""Clear cached detection results to force re-detection"""
self._cached_detection_valid = False
self._last_detection_result = None
logger.debug("Protontricks detection cache cleared")

View File

@@ -0,0 +1,171 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Resolution Service Module
Centralized service for managing resolution settings across CLI and GUI frontends
"""
import logging
from typing import Optional
from ..handlers.config_handler import ConfigHandler
# Initialize logger
logger = logging.getLogger(__name__)
class ResolutionService:
"""
Centralized service for managing resolution settings
Handles saving, loading, and validation of resolution settings
"""
def __init__(self):
"""Initialize the resolution service"""
self.config_handler = ConfigHandler()
logger.debug("ResolutionService initialized")
def save_resolution(self, resolution: str) -> bool:
"""
Save a resolution setting to configuration
Args:
resolution (str): The resolution to save (e.g., '1920x1080')
Returns:
bool: True if saved successfully, False otherwise
"""
try:
# Validate resolution format (basic check)
if not self._validate_resolution_format(resolution):
logger.warning("Invalid resolution format provided")
return False
success = self.config_handler.save_resolution(resolution)
if success:
logger.info(f"Resolution saved successfully: {resolution}")
else:
logger.error("Failed to save resolution")
return success
except Exception as e:
logger.error(f"Error in save_resolution: {e}")
return False
def get_saved_resolution(self) -> Optional[str]:
"""
Retrieve the saved resolution from configuration
Returns:
str: The saved resolution or None if not saved
"""
try:
resolution = self.config_handler.get_saved_resolution()
if resolution:
logger.debug(f"Retrieved saved resolution: {resolution}")
else:
logger.debug("No saved resolution found")
return resolution
except Exception as e:
logger.error(f"Error retrieving resolution: {e}")
return None
def has_saved_resolution(self) -> bool:
"""
Check if a resolution is saved in configuration
Returns:
bool: True if resolution exists, False otherwise
"""
try:
return self.config_handler.has_saved_resolution()
except Exception as e:
logger.error(f"Error checking for saved resolution: {e}")
return False
def clear_saved_resolution(self) -> bool:
"""
Clear the saved resolution from configuration
Returns:
bool: True if cleared successfully, False otherwise
"""
try:
success = self.config_handler.clear_saved_resolution()
if success:
logger.info("Resolution cleared successfully")
else:
logger.error("Failed to clear resolution")
return success
except Exception as e:
logger.error(f"Error clearing resolution: {e}")
return False
def _validate_resolution_format(self, resolution: str) -> bool:
"""
Validate resolution format (e.g., '1920x1080' or '1280x800 (Steam Deck)')
Args:
resolution (str): Resolution string to validate
Returns:
bool: True if valid format, False otherwise
"""
import re
if not resolution or resolution == 'Leave unchanged':
return True # Allow 'Leave unchanged' as valid
# Handle Steam Deck format: '1280x800 (Steam Deck)'
if ' (Steam Deck)' in resolution:
resolution = resolution.replace(' (Steam Deck)', '')
# Check for WxH format (e.g., 1920x1080)
if re.match(r'^[0-9]+x[0-9]+$', resolution):
# Extract width and height
try:
width, height = resolution.split('x')
width_int = int(width)
height_int = int(height)
# Basic sanity checks
if width_int > 0 and height_int > 0 and width_int <= 10000 and height_int <= 10000:
return True
else:
logger.warning(f"Resolution dimensions out of reasonable range: {resolution}")
return False
except ValueError:
logger.warning(f"Invalid resolution format: {resolution}")
return False
else:
logger.warning(f"Resolution does not match WxH format: {resolution}")
return False
def get_resolution_index(self, resolution: str, combo_items: list) -> int:
"""
Get the index of a resolution in a combo box list
Args:
resolution (str): Resolution to find
combo_items (list): List of combo box items
Returns:
int: Index of the resolution, or 0 (Leave unchanged) if not found
"""
if not resolution:
return 0 # Default to 'Leave unchanged'
# Handle Steam Deck special case
if resolution == '1280x800' and '1280x800 (Steam Deck)' in combo_items:
return combo_items.index('1280x800 (Steam Deck)')
# Try exact match first
if resolution in combo_items:
return combo_items.index(resolution)
# Try partial match (e.g., '1920x1080' in '1920x1080 (Steam Deck)')
for i, item in enumerate(combo_items):
if resolution in item:
return i
# Default to 'Leave unchanged'
return 0

View File

@@ -0,0 +1,419 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Resource Manager Module
Handles system resource limits for Jackify operations
"""
import resource
import logging
import os
from typing import Tuple, Optional
# Initialize logger
logger = logging.getLogger(__name__)
class ResourceManager:
"""
Manages system resource limits for Jackify operations
Focuses on file descriptor limits to resolve ulimit issues
"""
# Target file descriptor limit based on successful user testing
TARGET_FILE_DESCRIPTORS = 64556
def __init__(self):
"""Initialize the resource manager"""
self.original_limits = None
self.current_limits = None
self.target_achieved = False
logger.debug("ResourceManager initialized")
def get_current_file_descriptor_limits(self) -> Tuple[int, int]:
"""
Get current file descriptor limits (soft, hard)
Returns:
tuple: (soft_limit, hard_limit)
"""
try:
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
return soft, hard
except Exception as e:
logger.error(f"Error getting file descriptor limits: {e}")
return 0, 0
def increase_file_descriptor_limit(self, target_limit: Optional[int] = None) -> bool:
"""
Increase file descriptor limit to target value
Args:
target_limit (int, optional): Target limit. Defaults to TARGET_FILE_DESCRIPTORS
Returns:
bool: True if limit was increased or already adequate, False if failed
"""
if target_limit is None:
target_limit = self.TARGET_FILE_DESCRIPTORS
try:
# Get current limits
current_soft, current_hard = self.get_current_file_descriptor_limits()
self.original_limits = (current_soft, current_hard)
logger.info(f"Current file descriptor limits: soft={current_soft}, hard={current_hard}")
# Check if we already have adequate limits
if current_soft >= target_limit:
logger.info(f"File descriptor limit already adequate: {current_soft} >= {target_limit}")
self.target_achieved = True
self.current_limits = (current_soft, current_hard)
return True
# Calculate new soft limit (can't exceed hard limit)
new_soft = min(target_limit, current_hard)
if new_soft <= current_soft:
logger.warning(f"Cannot increase file descriptor limit: hard limit ({current_hard}) too low for target ({target_limit})")
self.current_limits = (current_soft, current_hard)
return False
# Attempt to set new limits
try:
resource.setrlimit(resource.RLIMIT_NOFILE, (new_soft, current_hard))
# Verify the change worked
verify_soft, verify_hard = self.get_current_file_descriptor_limits()
self.current_limits = (verify_soft, verify_hard)
if verify_soft >= new_soft:
logger.info(f"Successfully increased file descriptor limit: {current_soft} -> {verify_soft}")
self.target_achieved = (verify_soft >= target_limit)
if not self.target_achieved:
logger.warning(f"Increased limit ({verify_soft}) is below target ({target_limit}) but above original ({current_soft})")
return True
else:
logger.error(f"File descriptor limit increase failed verification: expected {new_soft}, got {verify_soft}")
return False
except (ValueError, OSError) as e:
logger.error(f"Failed to set file descriptor limit: {e}")
self.current_limits = (current_soft, current_hard)
return False
except Exception as e:
logger.error(f"Error in increase_file_descriptor_limit: {e}")
return False
def get_limit_status(self) -> dict:
"""
Get detailed status of file descriptor limits
Returns:
dict: Status information about limits
"""
current_soft, current_hard = self.get_current_file_descriptor_limits()
return {
'current_soft': current_soft,
'current_hard': current_hard,
'original_limits': self.original_limits,
'target_limit': self.TARGET_FILE_DESCRIPTORS,
'target_achieved': self.target_achieved,
'increase_needed': current_soft < self.TARGET_FILE_DESCRIPTORS,
'can_increase': current_hard >= self.TARGET_FILE_DESCRIPTORS,
'max_possible': current_hard
}
def get_manual_increase_instructions(self) -> dict:
"""
Get distribution-specific instructions for manually increasing limits
Returns:
dict: Instructions organized by distribution/method
"""
status = self.get_limit_status()
target = self.TARGET_FILE_DESCRIPTORS
# Detect distribution
distro = self._detect_distribution()
instructions = {
'target_limit': target,
'current_limit': status['current_soft'],
'distribution': distro,
'methods': {}
}
# Temporary increase (all distributions)
instructions['methods']['temporary'] = {
'title': 'Temporary Increase (Current Session Only)',
'commands': [
f'ulimit -n {target}',
'jackify # Re-run Jackify after setting ulimit'
],
'note': 'This only affects the current terminal session'
}
# Permanent increase (varies by distribution)
if distro in ['cachyos', 'arch', 'manjaro']:
instructions['methods']['permanent'] = {
'title': 'Permanent Increase (Arch-based Systems)',
'commands': [
'sudo nano /etc/security/limits.conf',
f'# Add these lines to the file:',
f'* soft nofile {target}',
f'* hard nofile {target}',
'# Save file and reboot, or logout/login'
],
'note': 'Requires root privileges and reboot/re-login'
}
elif distro in ['opensuse', 'suse']:
instructions['methods']['permanent'] = {
'title': 'Permanent Increase (openSUSE)',
'commands': [
'sudo nano /etc/security/limits.conf',
f'# Add these lines to the file:',
f'* soft nofile {target}',
f'* hard nofile {target}',
'# Save file and reboot, or logout/login',
'# Alternative: Set in systemd service file'
],
'note': 'May require additional systemd configuration on openSUSE'
}
else:
instructions['methods']['permanent'] = {
'title': 'Permanent Increase (Generic Linux)',
'commands': [
'sudo nano /etc/security/limits.conf',
f'# Add these lines to the file:',
f'* soft nofile {target}',
f'* hard nofile {target}',
'# Save file and reboot, or logout/login'
],
'note': 'Standard method for most Linux distributions'
}
return instructions
def _detect_distribution(self) -> str:
"""
Detect the Linux distribution
Returns:
str: Distribution identifier
"""
try:
# Check /etc/os-release
if os.path.exists('/etc/os-release'):
with open('/etc/os-release', 'r') as f:
content = f.read().lower()
if 'cachyos' in content:
return 'cachyos'
elif 'arch' in content:
return 'arch'
elif 'manjaro' in content:
return 'manjaro'
elif 'opensuse' in content or 'suse' in content:
return 'opensuse'
elif 'ubuntu' in content:
return 'ubuntu'
elif 'debian' in content:
return 'debian'
elif 'fedora' in content:
return 'fedora'
# Fallback detection methods
if os.path.exists('/etc/arch-release'):
return 'arch'
elif os.path.exists('/etc/SuSE-release'):
return 'opensuse'
except Exception as e:
logger.warning(f"Could not detect distribution: {e}")
return 'unknown'
def is_too_many_files_error(self, error_message: str) -> bool:
"""
Check if an error message indicates a 'too many open files' issue
Args:
error_message (str): Error message to check
Returns:
bool: True if error is related to file descriptor limits
"""
if not error_message:
return False
error_lower = error_message.lower()
indicators = [
'too many open files',
'too many files open',
'cannot open',
'emfile', # errno 24
'file descriptor',
'ulimit',
'resource temporarily unavailable'
]
return any(indicator in error_lower for indicator in indicators)
def apply_recommended_limits(self) -> bool:
"""
Apply recommended resource limits for Jackify operations
Returns:
bool: True if limits were successfully applied
"""
logger.info("Applying recommended resource limits for Jackify operations")
# Focus on file descriptor limits as the primary issue
success = self.increase_file_descriptor_limit()
if success:
status = self.get_limit_status()
logger.info(f"Resource limits applied successfully. Current file descriptors: {status['current_soft']}")
else:
logger.warning("Failed to apply optimal resource limits")
return success
def handle_too_many_files_error(self, error_message: str, context: str = "") -> dict:
"""
Handle a 'too many open files' error by attempting to increase limits and providing guidance
Args:
error_message (str): The error message that triggered this handler
context (str): Additional context about where the error occurred
Returns:
dict: Result of handling the error, including success status and guidance
"""
logger.warning(f"Detected 'too many open files' error in {context}: {error_message}")
result = {
'error_detected': True,
'error_message': error_message,
'context': context,
'auto_fix_attempted': False,
'auto_fix_success': False,
'manual_instructions': None,
'recommendation': ''
}
# Check if this is actually a file descriptor limit error
if not self.is_too_many_files_error(error_message):
result['error_detected'] = False
return result
# Get current status
status = self.get_limit_status()
# Attempt automatic fix if we haven't already optimized
if not self.target_achieved and status['can_increase']:
logger.info("Attempting to automatically increase file descriptor limits...")
result['auto_fix_attempted'] = True
success = self.increase_file_descriptor_limit()
result['auto_fix_success'] = success
if success:
new_status = self.get_limit_status()
result['recommendation'] = f"File descriptor limit increased to {new_status['current_soft']}. Please retry the operation."
logger.info(f"Successfully increased file descriptor limit to {new_status['current_soft']}")
else:
result['recommendation'] = "Automatic limit increase failed. Manual intervention required."
logger.warning("Automatic file descriptor limit increase failed")
else:
result['recommendation'] = "File descriptor limits already at maximum or cannot be increased automatically."
# Always provide manual instructions as fallback
result['manual_instructions'] = self.get_manual_increase_instructions()
return result
def show_guidance_dialog(self, parent=None):
"""
Show the ulimit guidance dialog (GUI only)
Args:
parent: Parent widget for the dialog
Returns:
Dialog result or None if not in GUI mode
"""
try:
# Only available in GUI mode
from jackify.frontends.gui.dialogs.ulimit_guidance_dialog import show_ulimit_guidance
return show_ulimit_guidance(parent, self)
except ImportError:
logger.debug("GUI ulimit guidance dialog not available (likely CLI mode)")
return None
# Convenience functions for easy use
def ensure_adequate_file_descriptor_limits() -> bool:
"""
Convenience function to ensure adequate file descriptor limits
Returns:
bool: True if limits are adequate or were successfully increased
"""
manager = ResourceManager()
return manager.apply_recommended_limits()
def handle_file_descriptor_error(error_message: str, context: str = "") -> dict:
"""
Convenience function to handle file descriptor limit errors
Args:
error_message (str): The error message that triggered this handler
context (str): Additional context about where the error occurred
Returns:
dict: Result of handling the error, including success status and guidance
"""
manager = ResourceManager()
return manager.handle_too_many_files_error(error_message, context)
# Module-level testing
if __name__ == '__main__':
# Configure logging for testing
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
print("Testing ResourceManager...")
manager = ResourceManager()
# Show current status
status = manager.get_limit_status()
print(f"\nCurrent Status:")
print(f" Current soft limit: {status['current_soft']}")
print(f" Current hard limit: {status['current_hard']}")
print(f" Target limit: {status['target_limit']}")
print(f" Increase needed: {status['increase_needed']}")
print(f" Can increase: {status['can_increase']}")
# Test limit increase
print(f"\nAttempting to increase limits...")
success = manager.apply_recommended_limits()
print(f"Success: {success}")
# Show final status
final_status = manager.get_limit_status()
print(f"\nFinal Status:")
print(f" Current soft limit: {final_status['current_soft']}")
print(f" Target achieved: {final_status['target_achieved']}")
# Test manual instructions
instructions = manager.get_manual_increase_instructions()
print(f"\nDetected distribution: {instructions['distribution']}")
print(f"Manual increase available if needed")
print("\nTesting completed successfully!")

View File

@@ -0,0 +1,274 @@
import os
import time
import subprocess
import signal
import psutil
import logging
import sys
from typing import Callable, Optional
logger = logging.getLogger(__name__)
def _get_clean_subprocess_env():
"""
Create a clean environment for subprocess calls by removing PyInstaller-specific
environment variables that can interfere with Steam execution.
Returns:
dict: Cleaned environment dictionary
"""
env = os.environ.copy()
pyinstaller_vars_removed = []
# Remove PyInstaller-specific environment variables
if env.pop('_MEIPASS', None):
pyinstaller_vars_removed.append('_MEIPASS')
if env.pop('_MEIPASS2', None):
pyinstaller_vars_removed.append('_MEIPASS2')
# Clean library path variables that PyInstaller modifies (Linux/Unix)
if 'LD_LIBRARY_PATH_ORIG' in env:
# Restore original LD_LIBRARY_PATH if it was backed up by PyInstaller
env['LD_LIBRARY_PATH'] = env['LD_LIBRARY_PATH_ORIG']
pyinstaller_vars_removed.append('LD_LIBRARY_PATH (restored from _ORIG)')
else:
# Remove PyInstaller-modified LD_LIBRARY_PATH
if env.pop('LD_LIBRARY_PATH', None):
pyinstaller_vars_removed.append('LD_LIBRARY_PATH (removed)')
# Clean PATH of PyInstaller-specific entries
if 'PATH' in env and hasattr(sys, '_MEIPASS'):
path_entries = env['PATH'].split(os.pathsep)
original_count = len(path_entries)
# Remove any PATH entries that point to PyInstaller temp directory
cleaned_path = [p for p in path_entries if not p.startswith(sys._MEIPASS)]
env['PATH'] = os.pathsep.join(cleaned_path)
if len(cleaned_path) < original_count:
pyinstaller_vars_removed.append(f'PATH (removed {original_count - len(cleaned_path)} PyInstaller entries)')
# Clean macOS library path (if present)
if 'DYLD_LIBRARY_PATH' in env and hasattr(sys, '_MEIPASS'):
dyld_entries = env['DYLD_LIBRARY_PATH'].split(os.pathsep)
cleaned_dyld = [p for p in dyld_entries if not p.startswith(sys._MEIPASS)]
if cleaned_dyld:
env['DYLD_LIBRARY_PATH'] = os.pathsep.join(cleaned_dyld)
pyinstaller_vars_removed.append('DYLD_LIBRARY_PATH (cleaned)')
else:
env.pop('DYLD_LIBRARY_PATH', None)
pyinstaller_vars_removed.append('DYLD_LIBRARY_PATH (removed)')
# Log what was cleaned for debugging
if pyinstaller_vars_removed:
logger.debug(f"Steam restart: Cleaned PyInstaller environment variables: {', '.join(pyinstaller_vars_removed)}")
else:
logger.debug("Steam restart: No PyInstaller environment variables detected (likely DEV mode)")
return env
class SteamRestartError(Exception):
pass
def is_steam_deck() -> bool:
"""Detect if running on Steam Deck/SteamOS."""
try:
if os.path.exists('/etc/os-release'):
with open('/etc/os-release', 'r') as f:
content = f.read().lower()
if 'steamos' in content or 'steam deck' in content:
return True
if os.path.exists('/sys/devices/virtual/dmi/id/product_name'):
with open('/sys/devices/virtual/dmi/id/product_name', 'r') as f:
if 'steam deck' in f.read().lower():
return True
if os.environ.get('STEAM_RUNTIME') and os.path.exists('/home/deck'):
return True
except Exception as e:
logger.debug(f"Error detecting Steam Deck: {e}")
return False
def get_steam_processes() -> list:
"""Return a list of psutil.Process objects for running Steam processes."""
steam_procs = []
for proc in psutil.process_iter(['pid', 'name', 'exe', 'cmdline']):
try:
name = proc.info['name']
exe = proc.info['exe']
cmdline = proc.info['cmdline']
if name and 'steam' in name.lower():
steam_procs.append(proc)
elif exe and 'steam' in exe.lower():
steam_procs.append(proc)
elif cmdline and any('steam' in str(arg).lower() for arg in cmdline):
steam_procs.append(proc)
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
continue
return steam_procs
def wait_for_steam_exit(timeout: int = 60, check_interval: float = 0.5) -> bool:
"""Wait for all Steam processes to exit using pgrep (matching existing logic)."""
start = time.time()
env = _get_clean_subprocess_env()
while time.time() - start < timeout:
try:
result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=env)
if result.returncode != 0:
return True
except Exception as e:
logger.debug(f"Error checking Steam processes: {e}")
time.sleep(check_interval)
return False
def start_steam() -> bool:
"""Attempt to start Steam using the exact methods from existing working logic."""
env = _get_clean_subprocess_env()
try:
# Try systemd user service (Steam Deck)
if is_steam_deck():
subprocess.Popen(["systemctl", "--user", "restart", "app-steam@autostart.service"], env=env)
return True
# Use startup methods with only -silent flag (no -minimized or -no-browser)
start_methods = [
{"name": "Popen", "cmd": ["steam", "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True, "env": env}},
{"name": "setsid", "cmd": ["setsid", "steam", "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "env": env}},
{"name": "nohup", "cmd": ["nohup", "steam", "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True, "preexec_fn": os.setpgrp, "env": env}}
]
for method in start_methods:
method_name = method["name"]
logger.info(f"Attempting to start Steam using method: {method_name}")
try:
process = subprocess.Popen(method["cmd"], **method["kwargs"])
if process is not None:
logger.info(f"Initiated Steam start with {method_name}.")
time.sleep(5) # Wait 5 seconds as in existing logic
check_result = subprocess.run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10, env=env)
if check_result.returncode == 0:
logger.info(f"Steam process detected after using {method_name}. Proceeding to wait phase.")
return True
else:
logger.warning(f"Steam process not detected after initiating with {method_name}. Trying next method.")
else:
logger.warning(f"Failed to start process with {method_name}. Trying next method.")
except FileNotFoundError:
logger.error(f"Command not found for method {method_name} (e.g., setsid, nohup). Trying next method.")
except Exception as e:
logger.error(f"Error starting Steam with {method_name}: {e}. Trying next method.")
return False
except Exception as e:
logger.error(f"Error starting Steam: {e}")
return False
def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = None, timeout: int = 60) -> bool:
"""
Robustly restart Steam across all distros. Returns True on success, False on failure.
Optionally accepts a progress_callback(message: str) for UI feedback.
Uses aggressive pkill approach for maximum reliability.
"""
env = _get_clean_subprocess_env()
def report(msg):
logger.info(msg)
if progress_callback:
progress_callback(msg)
report("Shutting down Steam...")
# Steam Deck: Use systemctl for shutdown (special handling)
if is_steam_deck():
try:
report("Steam Deck detected - using systemctl shutdown...")
subprocess.run(['systemctl', '--user', 'stop', 'app-steam@autostart.service'],
timeout=15, check=False, capture_output=True, env=env)
time.sleep(2)
except Exception as e:
logger.debug(f"systemctl stop failed on Steam Deck: {e}")
# All systems: Use pkill approach (proven 15/16 test success rate)
try:
# Skip unreliable steam -shutdown, go straight to pkill
pkill_result = subprocess.run(['pkill', 'steam'], timeout=15, check=False, capture_output=True, env=env)
logger.debug(f"pkill steam result: {pkill_result.returncode}")
time.sleep(2)
# Check if Steam is still running
check_result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=env)
if check_result.returncode == 0:
# Force kill if still running
report("Steam still running - force terminating...")
force_result = subprocess.run(['pkill', '-9', 'steam'], timeout=15, check=False, capture_output=True, env=env)
logger.debug(f"pkill -9 steam result: {force_result.returncode}")
time.sleep(2)
# Final check
final_check = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=env)
if final_check.returncode != 0:
logger.info("Steam processes successfully force terminated.")
else:
report("Failed to terminate Steam processes.")
return False
else:
logger.info("Steam processes successfully terminated.")
except Exception as e:
logger.error(f"Error during Steam shutdown: {e}")
report("Failed to shut down Steam.")
return False
report("Steam closed successfully.")
# Start Steam using platform-specific logic
report("Starting Steam...")
# Steam Deck: Use systemctl restart (keep existing working approach)
if is_steam_deck():
try:
subprocess.Popen(["systemctl", "--user", "restart", "app-steam@autostart.service"], env=env)
logger.info("Steam Deck: Initiated systemctl restart")
except Exception as e:
logger.error(f"Steam Deck systemctl restart failed: {e}")
report("Failed to restart Steam on Steam Deck.")
return False
else:
# All other distros: Use proven steam -silent method
if not start_steam():
report("Failed to start Steam.")
return False
# Wait for Steam to fully initialize using existing logic
report("Waiting for Steam to fully start")
logger.info("Waiting up to 2 minutes for Steam to fully initialize...")
max_startup_wait = 120
elapsed_wait = 0
initial_wait_done = False
while elapsed_wait < max_startup_wait:
try:
result = subprocess.run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10, env=env)
if result.returncode == 0:
if not initial_wait_done:
logger.info("Steam process detected. Waiting additional time for full initialization...")
initial_wait_done = True
time.sleep(5)
elapsed_wait += 5
if initial_wait_done and elapsed_wait >= 15:
final_check = subprocess.run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10, env=env)
if final_check.returncode == 0:
report("Steam started successfully.")
logger.info("Steam confirmed running after wait.")
return True
else:
logger.warning("Steam process disappeared during final initialization wait.")
break
else:
logger.debug(f"Steam process not yet detected. Waiting... ({elapsed_wait + 5}s)")
time.sleep(5)
elapsed_wait += 5
except Exception as e:
logger.warning(f"Error during Steam startup wait: {e}")
time.sleep(5)
elapsed_wait += 5
report("Steam did not start within timeout.")
logger.error("Steam failed to start/initialize within the allowed time.")
return False