mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 00:07:45 +02:00
316 lines
15 KiB
Python
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
|
|
|
|
|