Files
Jackify/jackify/backend/handlers/modlist_handler.py
2026-02-07 18:26:54 +00:00

316 lines
15 KiB
Python

from pathlib import Path
import json
import logging
from typing import Union, Dict, Optional, List, Tuple
import re
import time
import vdf
import os
import subprocess
import shutil
import requests
import atexit
import signal
import sys
# Import our modules
from .path_handler import PathHandler
from .filesystem_handler import FileSystemHandler
from .protontricks_handler import ProtontricksHandler
from .shortcut_handler import ShortcutHandler
from .resolution_handler import ResolutionHandler
# Import our safe VDF handler
from .vdf_handler import VDFHandler
from .modlist_detection import ModlistDetectionMixin
from .modlist_configuration import ModlistConfigurationMixin
from .modlist_wine_ops import ModlistWineOpsMixin
# Import colors from the new central location
from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_SELECTION, COLOR_ERROR
# Standard logging (no file handler)
import logging
# Initialize logger
logger = logging.getLogger(__name__)
# Ensure terminal state is restored on exit, error, or interrupt
def _restore_terminal():
try:
# Skip stty in GUI mode to prevent "Inappropriate ioctl for device" error
if os.environ.get('JACKIFY_GUI_MODE') == '1':
return
os.system('stty sane')
except Exception:
pass
# Only register signal handlers if we're in the main thread
try:
import threading
if threading.current_thread() is threading.main_thread():
atexit.register(_restore_terminal)
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
signal.signal(sig, lambda signum, frame: (_restore_terminal(), sys.exit(1)))
except Exception:
# If signal handling fails, just continue without it
pass
class ModlistHandler(ModlistDetectionMixin, ModlistConfigurationMixin, ModlistWineOpsMixin):
"""
Handles operations related to modlist detection and configuration
"""
# Dictionary mapping modlist name patterns (lowercase, spaces optional)
# to lists of additional Wine components or special actions.
MODLIST_SPECIFIC_COMPONENTS = {
# Pattern: [component1, component2, ... or special_action_string]
"wildlander": ["dotnet48"], # Example from bash script
"licentia": ["dotnet8"], # Example from bash script (needs special handling)
"nolvus": ["dotnet6", "dotnet7"], # Example
# Add other modlists and their specific needs here
# e.g., "fallout4_anotherlife": ["some_component"]
}
# Canonical mapping of modlist-specific Wine components (from omni-guides.sh)
# dotnet4.x components disabled in v0.1.6.2 -- replaced with universal registry fixes
MODLIST_WINE_COMPONENTS = {
# "wildlander": ["dotnet472"], # DISABLED: Universal registry fixes replace dotnet472 installation
# "librum": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40
"librum": ["dotnet8"], # dotnet40 replaced with universal registry fixes
# "apostasy": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40
"apostasy": ["dotnet8"], # dotnet40 replaced with universal registry fixes
# "nordicsouls": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
# "livingskyrim": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
# "lsiv": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
# "ls4": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
# "lorerim": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
# "lostlegacy": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
}
def __init__(self, steam_path_or_config: Union[Dict, str, Path, None] = None,
mo2_path: Optional[Union[str, Path]] = None,
steamdeck: bool = False,
verbose: bool = False, # Add verbose flag
filesystem_handler: Optional['FileSystemHandler'] = None):
"""
Initialize the ModlistHandler.
Can be initialized with:
1. A config dictionary: ModlistHandler(config_dict, steamdeck=True)
2. Explicit paths: ModlistHandler(steam_path="/path/to/steam", mo2_path="/path/to/mo2", steamdeck=False)
3. Default (will try to find paths if needed later): ModlistHandler()
Args:
steam_path_or_config: Config dict or path to Steam installation.
mo2_path: Path to ModOrganizer installation (needed if steam_path_or_config is a path).
steamdeck: Boolean indicating if running on Steam Deck.
verbose: Boolean indicating if verbose output is desired.
filesystem_handler: Optional FileSystemHandler instance to use instead of creating a new one.
"""
# Use standard logging (propagate to root logger so messages appear in logs)
self.logger = logging.getLogger(__name__)
self.logger.propagate = True
self.steamdeck = steamdeck
# DEBUG: Log ModlistHandler instantiation details for SD card path debugging
import traceback
caller_info = traceback.extract_stack()[-2] # Get caller info
self.logger.debug(f"[SD_CARD_DEBUG] ModlistHandler created: id={id(self)}, steamdeck={steamdeck}")
self.logger.debug(f"[SD_CARD_DEBUG] Created from: {caller_info.filename}:{caller_info.lineno} in {caller_info.name}()")
self.steam_path: Optional[Path] = None
self.verbose = verbose # Store verbose flag
self.mo2_path: Optional[Path] = None
if isinstance(steam_path_or_config, dict):
# Scenario 1: Init with config dict
self.logger.debug("Initializing ModlistHandler with config dict")
steam_path_str = steam_path_or_config.get('steam_path')
self.steam_path = Path(steam_path_str) if steam_path_str else None
mo2_path_str = steam_path_or_config.get('mo2_path')
self.mo2_path = Path(mo2_path_str) if mo2_path_str else None
elif steam_path_or_config:
# Scenario 2: Init with explicit paths
self.logger.debug("Initializing ModlistHandler with explicit paths")
self.steam_path = Path(steam_path_or_config)
if mo2_path:
self.mo2_path = Path(mo2_path)
else:
# Decide if mo2_path is strictly required here
self.logger.warning("MO2 path not provided during path-based initialization")
# If MO2 path is essential, raise ValueError
# raise ValueError("mo2_path is required when providing steam_path directly")
else:
# Scenario 3: Default init, paths might be found later if needed
self.logger.debug("Initializing ModlistHandler with default settings")
# Paths remain None for now
self.modlists: Dict[str, Dict] = {}
self.launch_options = [
"--no-sandbox",
"--disable-gpu-sandbox",
"--disable-software-rasterizer",
"--disable-dev-shm-usage"
]
# Initialize state reset variables first
self.modlist = None
self.appid = None
self.game_var = None
self.game_var_full = None
self.modlist_dir = None
self.modlist_ini = None
self.steam_library = None
self.basegame_sdcard = False
self.modlist_sdcard = False
self.compat_data_path = None
self.proton_ver = None
self.game_name = None
self.selected_resolution = None
self.which_protontricks = None
self.steamdeck = steamdeck
self.stock_game_path = None
# Initialize Handlers (should happen regardless of how paths were provided)
self.protontricks_handler = ProtontricksHandler(self.steamdeck, logger=self.logger)
# Initialize winetricks handler for wine component installation
from .winetricks_handler import WinetricksHandler
self.winetricks_handler = WinetricksHandler(logger=self.logger)
self.shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck, verbose=self.verbose)
self.filesystem_handler = filesystem_handler if filesystem_handler else FileSystemHandler()
self.resolution_handler = ResolutionHandler()
self.path_handler = PathHandler() # Assuming PathHandler is needed
# Use shared timing for consistency across services
# Load modlists if steam_path is known
if self.steam_path:
self._load_modlists()
else:
self.logger.debug("Steam path not known during init, skipping initial modlist load.")
# Use static methods from VDFHandler
self.vdf_handler = VDFHandler
def _get_progress_timestamp(self):
"""Get consistent progress timestamp"""
from jackify.shared.timing import get_timestamp
return get_timestamp()
# --- Original methods continue below ---
def _load_modlists(self) -> None:
"""Load modlists from local configuration or detect from Steam shortcuts."""
try:
# Try to load from local config first
if not self.steam_path or not self.steam_path.exists():
self.logger.warning("Steam path not valid in __init__, cannot load modlists.json")
self._detect_modlists_from_shortcuts()
return
config_path = self.steam_path.parent / 'modlists.json'
if config_path.exists():
with open(config_path, 'r') as f:
self.modlists = json.load(f)
self.logger.info("Loaded modlists from local configuration")
return
self._detect_modlists_from_shortcuts()
except Exception as e:
self.logger.error(f"Error loading modlists: {e}")
def set_modlist(self, modlist_info: Dict) -> bool:
"""Sets the internal context based on the selected modlist dictionary.
Extracts AppName, AppID, and StartDir from the input dictionary
and sets internal state variables like self.game_name, self.appid,
self.modlist_dir, self.modlist_ini.
Args:
modlist_info: Dictionary containing {'name', 'appid', 'path'}.
Returns:
True if the context was successfully set, False otherwise.
"""
self.logger.info(f"Setting context for selected modlist: {modlist_info.get('name')}")
# 1. Extract info from dictionary
app_name = modlist_info.get('name')
app_id = modlist_info.get('appid')
modlist_dir_path_str = modlist_info.get('path')
if not all([app_name, app_id, modlist_dir_path_str]):
self.logger.error(f"Incomplete modlist info provided: {modlist_info}")
return False
self.logger.debug(f"Using AppName: {app_name}, AppID: {app_id}, Path: {modlist_dir_path_str}")
modlist_dir_path = Path(modlist_dir_path_str)
# 2. Validate paths and set internal state
if not modlist_dir_path.is_dir():
self.logger.error(f"Modlist directory does not exist: {modlist_dir_path}")
return False
modlist_ini_path = modlist_dir_path / "ModOrganizer.ini"
if not modlist_ini_path.is_file():
self.logger.error(f"ModOrganizer.ini not found in directory: {modlist_dir_path}")
return False
# Set state variables
self.game_name = app_name
self.appid = str(app_id) # Ensure AppID is always stored as string
self.modlist_dir = Path(modlist_dir_path_str)
self.modlist_ini = modlist_ini_path
# Determine if modlist is on SD card (Steam Deck only)
# On non-Steam Deck systems, /media mounts should use Z: drive, not D: drive
is_on_sdcard_path = str(self.modlist_dir).startswith("/run/media") or str(self.modlist_dir).startswith("/media")
# Log SD card detection for debugging
self.logger.debug(f"SD card detection: modlist_dir={self.modlist_dir}, is_sdcard_path={is_on_sdcard_path}, steamdeck={self.steamdeck}")
if is_on_sdcard_path and self.steamdeck:
self.modlist_sdcard = True
self.logger.info("Modlist appears to be on an SD card (Steam Deck).")
self.logger.debug(f"Set modlist_sdcard=True")
else:
self.modlist_sdcard = False
self.logger.debug(f"Set modlist_sdcard=False (is_on_sdcard_path={is_on_sdcard_path}, steamdeck={self.steamdeck})")
if is_on_sdcard_path and not self.steamdeck:
self.logger.info("Modlist on /media mount detected on non-Steam Deck system - using Z: drive mapping.")
# Find and set compatdata path now that we have appid
# Ensure PathHandler is available (should be initialized in __init__)
if hasattr(self, 'path_handler'):
# Convert appid to string since find_compat_data expects a string
appid_str = str(self.appid)
self.compat_data_path = self.path_handler.find_compat_data(appid_str)
if self.compat_data_path:
self.logger.debug(f"Found compatdata path: {self.compat_data_path}")
else:
self.logger.warning(f"Could not find compatdata path for AppID {self.appid}")
else:
self.logger.error("PathHandler not initialized, cannot find compatdata path.")
self.compat_data_path = None # Ensure it's None if handler missing
self.logger.info(f"Modlist context set successfully for '{self.game_name}' (AppID: {self.appid})")
self.logger.debug(f" Directory: {self.modlist_dir}")
self.logger.debug(f" INI Path: {self.modlist_ini}")
self.logger.debug(f" On SD Card: {self.modlist_sdcard}")
# Store engine_installed flag for conditional path manipulation
self.engine_installed = modlist_info.get('engine_installed', False)
self.logger.debug(f" Engine Installed: {self.engine_installed}")
# Store download_dir when known (Install a Modlist flow); Configure New/Existing leave None
self.download_dir = modlist_info.get('download_dir')
if self.download_dir:
self.logger.debug(f" Download dir (for MO2): {self.download_dir}")
# Call internal detection methods to populate more state
if not self._detect_game_variables():
self.logger.warning("Failed to auto-detect game type after setting context.")
# Decide if failure to detect game should make set_modlist return False
# return False
return True