mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-01-17 19:47:00 +01:00
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:
5
jackify/backend/services/__init__.py
Normal file
5
jackify/backend/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Backend Services
|
||||
|
||||
High-level service classes that orchestrate handlers.
|
||||
"""
|
||||
271
jackify/backend/services/api_key_service.py
Normal file
271
jackify/backend/services/api_key_service.py
Normal 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)}"
|
||||
2739
jackify/backend/services/automated_prefix_service.py
Normal file
2739
jackify/backend/services/automated_prefix_service.py
Normal file
File diff suppressed because it is too large
Load Diff
690
jackify/backend/services/modlist_service.py
Normal file
690
jackify/backend/services/modlist_service.py
Normal 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
|
||||
406
jackify/backend/services/native_steam_service.py
Normal file
406
jackify/backend/services/native_steam_service.py
Normal 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
|
||||
220
jackify/backend/services/protontricks_detection_service.py
Normal file
220
jackify/backend/services/protontricks_detection_service.py
Normal 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")
|
||||
171
jackify/backend/services/resolution_service.py
Normal file
171
jackify/backend/services/resolution_service.py
Normal 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
|
||||
419
jackify/backend/services/resource_manager.py
Normal file
419
jackify/backend/services/resource_manager.py
Normal 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!")
|
||||
274
jackify/backend/services/steam_restart_service.py
Normal file
274
jackify/backend/services/steam_restart_service.py
Normal 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
|
||||
Reference in New Issue
Block a user