#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Configuration Handler Module Handles application settings and configuration """ import os import sys import json import logging import shutil import re from pathlib import Path from typing import Optional from .config_handler_encryption import ConfigEncryptionMixin from .config_handler_directories import ConfigDirectoriesMixin from .config_handler_proton import ConfigProtonMixin from jackify.shared.steam_utils import ( STEAM_PREFERENCE_AUTO, resolve_preferred_steam_installation, ) logger = logging.getLogger(__name__) class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonMixin): """ Handles application configuration and settings Singleton pattern ensures all code shares the same instance """ _instance = None _initialized = False def __new__(cls): if cls._instance is None: cls._instance = super(ConfigHandler, cls).__new__(cls) return cls._instance def __init__(self): """Initialize configuration handler with default settings""" # Only initialize once (singleton pattern) if ConfigHandler._initialized: return ConfigHandler._initialized = True self.config_dir = os.path.expanduser("~/.config/jackify") self.config_file = os.path.join(self.config_dir, "config.json") self.settings = { "version": "0.2.0", "last_selected_modlist": None, "steam_libraries": [], "resolution": None, "protontricks_path": None, "steam_path": None, "steam_install_preference": STEAM_PREFERENCE_AUTO, # auto|flatpak|native "nexus_api_key": None, # Base64 encoded API key "default_install_parent_dir": None, # Parent directory for modlist installations "default_download_parent_dir": None, # Parent directory for downloads "modlist_install_base_dir": os.path.expanduser("~/Games"), # Configurable base directory for modlist installations "modlist_downloads_base_dir": os.path.expanduser("~/Games/Modlist_Downloads"), # Configurable base directory for downloads "jackify_data_dir": None, # Configurable Jackify data directory (default: ~/Jackify) "use_winetricks_for_components": True, # DEPRECATED: Migrated to component_installation_method. Kept for backward compatibility. "component_installation_method": "winetricks", # "winetricks" (default) or "system_protontricks" "game_proton_path": None, # Proton version for game shortcuts (can be any Proton 9+), separate from install proton "proton_path": None, # Install Proton path (for jackify-engine) - None means auto-detect "proton_version": None, # Install Proton version name - None means auto-detect "steam_restart_strategy": "jackify", # "jackify" (default) or "simple" "manual_download_concurrent_limit": 2, # Shared GUI/CLI default for manual download browser tabs "manual_download_watch_directory": None, # Optional override for manual-download watcher folder "window_width": None, # Saved window width (None = use dynamic sizing) "window_height": None, # Saved window height (None = use dynamic sizing) } # Load configuration if exists self._load_config() # Perform version migrations self._migrate_config() # Normalize/repair Proton selections on every startup so stale deleted versions # cannot break workflows. self.normalize_proton_paths_on_boot() # If steam_path is not set, detect it if not self.settings["steam_path"]: self.settings["steam_path"] = self._detect_steam_path() # If jackify_data_dir is not set, initialize it to default if not self.settings.get("jackify_data_dir"): self.settings["jackify_data_dir"] = os.path.expanduser("~/Jackify") # Save the updated settings self.save_config() def _detect_steam_path(self): """ Detect the Steam installation path Returns: str: Path to the Steam installation or None if not found """ logger.info("Detecting Steam installation path...") preference = self.settings.get("steam_install_preference", STEAM_PREFERENCE_AUTO) install_type, install_root = resolve_preferred_steam_installation(preference=preference) if install_root: logger.info( "Selected Steam installation: %s (%s)", install_type, install_root, ) return str(install_root) logger.error("Steam installation not found") return None def _load_config(self): """ Load configuration from file and update in-memory cache. For legacy compatibility with initialization code. """ try: if os.path.exists(self.config_file): with open(self.config_file, 'r') as f: saved_config = json.load(f) # Update settings with saved values while preserving defaults self.settings.update(saved_config) logger.debug("Loaded configuration from file") else: logger.debug("No configuration file found, using defaults") self._create_config_dir() except Exception as e: logger.error(f"Error loading configuration: {e}") def _migrate_config(self): """ Migrate configuration between versions Handles breaking changes and data format updates """ current_version = self.settings.get("version", "0.0.0") target_version = "0.2.0" if current_version == target_version: return logger.info(f"Migrating config from {current_version} to {target_version}") # Migration: v0.0.x -> v0.2.0 # Encryption changed from cryptography (Fernet) to pycryptodome (AES-GCM) # Old encrypted API keys cannot be decrypted, must be re-entered from packaging import version if version.parse(current_version) < version.parse("0.2.0"): # Clear old encrypted credentials if self.settings.get("nexus_api_key"): logger.warning("Clearing saved API key due to encryption format change") logger.warning("Please re-enter your Nexus API key in Settings") self.settings["nexus_api_key"] = None # Clear OAuth token file (different encryption format) oauth_token_file = Path(self.config_dir) / "nexus-oauth.json" if oauth_token_file.exists(): logger.warning("Clearing saved OAuth token due to encryption format change") logger.warning("Please re-authorize with Nexus Mods") try: oauth_token_file.unlink() except Exception as e: logger.error(f"Failed to remove old OAuth token: {e}") # Remove obsolete keys obsolete_keys = [ "hoolamike_install_path", "hoolamike_version", "api_key_fallback_enabled", "proton_version", # Display string only, path stored in proton_path "game_proton_version" # Display string only, path stored in game_proton_path ] removed_count = 0 for key in obsolete_keys: if key in self.settings: del self.settings[key] removed_count += 1 if removed_count > 0: logger.info(f"Removed {removed_count} obsolete config keys") # Update version self.settings["version"] = target_version self.save_config() logger.info("Config migration completed") def _read_config_from_disk(self): """ Read configuration directly from disk without caching. Returns merged config (defaults + saved values). """ try: config = self.settings.copy() # Start with defaults if os.path.exists(self.config_file): with open(self.config_file, 'r') as f: saved_config = json.load(f) config.update(saved_config) return config except Exception as e: # Use logger.warning instead of print to stderr - logger is initialized before config access logger.warning(f"Error reading configuration from disk: {e}") return self.settings.copy() def reload_config(self): """Reload configuration from disk to pick up external changes""" self._load_config() def _create_config_dir(self): """Create configuration directory if it doesn't exist""" try: os.makedirs(self.config_dir, exist_ok=True) logger.debug(f"Created configuration directory: {self.config_dir}") except Exception as e: logger.error(f"Error creating configuration directory: {e}") def save_config(self): """Save current configuration to file""" try: self._create_config_dir() with open(self.config_file, 'w') as f: json.dump(self.settings, f, indent=2) logger.debug("Saved configuration to file") return True except Exception as e: logger.error(f"Error saving configuration: {e}") return False def get(self, key, default=None): """ Get a configuration value by key. Always reads fresh from disk to avoid stale data. """ config = self._read_config_from_disk() return config.get(key, default) def set(self, key, value): """Set a configuration value""" self.settings[key] = value return True def update(self, settings_dict): """Update multiple configuration values""" self.settings.update(settings_dict) return True def add_steam_library(self, path): """Add a Steam library path to configuration""" if path not in self.settings["steam_libraries"]: self.settings["steam_libraries"].append(path) logger.debug(f"Added Steam library: {path}") return True return False def remove_steam_library(self, path): """Remove a Steam library path from configuration""" if path in self.settings["steam_libraries"]: self.settings["steam_libraries"].remove(path) logger.debug(f"Removed Steam library: {path}") return True return False def set_resolution(self, width, height): """Set preferred resolution""" resolution = f"{width}x{height}" self.settings["resolution"] = resolution logger.debug(f"Set resolution to: {resolution}") return True def get_resolution(self): """Get preferred resolution""" return self.settings.get("resolution") def set_last_modlist(self, modlist_name): """Save the last selected modlist""" self.settings["last_selected_modlist"] = modlist_name logger.debug(f"Set last selected modlist to: {modlist_name}") return True def get_last_modlist(self): """Get the last selected modlist""" return self.settings.get("last_selected_modlist") def set_protontricks_path(self, path): """Set the path to protontricks executable""" self.settings["protontricks_path"] = path logger.debug(f"Set protontricks path to: {path}") return True def get_protontricks_path(self): """Get the path to protontricks executable""" return self.settings.get("protontricks_path") def save_resolution(self, resolution): """ Save resolution setting to configuration Args: resolution (str): Resolution string (e.g., '1920x1080') Returns: bool: True if saved successfully, False otherwise """ try: if resolution and resolution != 'Leave unchanged': self.settings["resolution"] = resolution logger.debug(f"Resolution saved: {resolution}") else: # Clear resolution if 'Leave unchanged' or empty self.settings["resolution"] = None logger.debug("Resolution cleared") return self.save_config() except Exception as e: logger.error(f"Error saving resolution: {e}") return False def get_saved_resolution(self): """ Retrieve the saved resolution from configuration Returns: str: Saved resolution or None if not saved """ try: resolution = self.settings.get("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): """ Check if a resolution is saved in configuration Returns: bool: True if resolution exists, False otherwise """ return self.settings.get("resolution") is not None def clear_saved_resolution(self): """ Clear the saved resolution from configuration Returns: bool: True if cleared successfully, False otherwise """ try: self.settings["resolution"] = None logger.debug("Resolution cleared from configuration") return self.save_config() except Exception as e: logger.error(f"Error clearing resolution: {e}") return False