mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-17 12:17:45 +02:00
Sync from development - prepare for v0.3.0
This commit is contained in:
@@ -11,16 +11,17 @@ import json
|
||||
import logging
|
||||
import shutil
|
||||
import re
|
||||
import base64
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Initialize logger
|
||||
from .config_handler_encryption import ConfigEncryptionMixin
|
||||
from .config_handler_directories import ConfigDirectoriesMixin
|
||||
from .config_handler_proton import ConfigProtonMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigHandler:
|
||||
class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonMixin):
|
||||
"""
|
||||
Handles application configuration and settings
|
||||
Singleton pattern ensures all code shares the same instance
|
||||
@@ -60,7 +61,7 @@ class ConfigHandler:
|
||||
"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 "nak_simple"
|
||||
"steam_restart_strategy": "jackify", # "jackify" (default) or "simple"
|
||||
"window_width": None, # Saved window width (None = use dynamic sizing)
|
||||
"window_height": None # Saved window height (None = use dynamic sizing)
|
||||
}
|
||||
@@ -214,8 +215,8 @@ class ConfigHandler:
|
||||
config.update(saved_config)
|
||||
return config
|
||||
except Exception as e:
|
||||
# Don't use logger here - can cause recursion if logger tries to access config
|
||||
print(f"Warning: Error reading configuration from disk: {e}", file=sys.stderr)
|
||||
# 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):
|
||||
@@ -305,224 +306,8 @@ class ConfigHandler:
|
||||
|
||||
def get_protontricks_path(self):
|
||||
"""Get the path to protontricks executable"""
|
||||
return self.settings.get("protontricks_path")
|
||||
|
||||
def _get_encryption_key(self) -> bytes:
|
||||
"""
|
||||
Generate encryption key for API key storage using same method as OAuth tokens
|
||||
return self.settings.get("protontricks_path")
|
||||
|
||||
Returns:
|
||||
Fernet-compatible encryption key
|
||||
"""
|
||||
import socket
|
||||
import getpass
|
||||
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
username = getpass.getuser()
|
||||
|
||||
# Try to get machine ID
|
||||
machine_id = None
|
||||
try:
|
||||
with open('/etc/machine-id', 'r') as f:
|
||||
machine_id = f.read().strip()
|
||||
except:
|
||||
try:
|
||||
with open('/var/lib/dbus/machine-id', 'r') as f:
|
||||
machine_id = f.read().strip()
|
||||
except:
|
||||
pass
|
||||
|
||||
if machine_id:
|
||||
key_material = f"{hostname}:{username}:{machine_id}:jackify"
|
||||
else:
|
||||
key_material = f"{hostname}:{username}:jackify"
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get machine info for encryption: {e}")
|
||||
key_material = "jackify:default:key"
|
||||
|
||||
# Generate Fernet-compatible key
|
||||
key_bytes = hashlib.sha256(key_material.encode('utf-8')).digest()
|
||||
return base64.urlsafe_b64encode(key_bytes)
|
||||
|
||||
def _encrypt_api_key(self, api_key: str) -> str:
|
||||
"""
|
||||
Encrypt API key using AES-GCM
|
||||
|
||||
Args:
|
||||
api_key: Plain text API key
|
||||
|
||||
Returns:
|
||||
Encrypted API key string
|
||||
"""
|
||||
try:
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Random import get_random_bytes
|
||||
|
||||
# Derive 32-byte AES key
|
||||
key = base64.urlsafe_b64decode(self._get_encryption_key())
|
||||
|
||||
# Generate random nonce
|
||||
nonce = get_random_bytes(12)
|
||||
|
||||
# Encrypt with AES-GCM
|
||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
||||
ciphertext, tag = cipher.encrypt_and_digest(api_key.encode('utf-8'))
|
||||
|
||||
# Combine and encode
|
||||
combined = nonce + ciphertext + tag
|
||||
return base64.b64encode(combined).decode('utf-8')
|
||||
|
||||
except ImportError:
|
||||
# Fallback to base64 if pycryptodome not available
|
||||
logger.warning("pycryptodome not available, using base64 encoding (less secure)")
|
||||
return base64.b64encode(api_key.encode('utf-8')).decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.error(f"Error encrypting API key: {e}")
|
||||
return ""
|
||||
|
||||
def _decrypt_api_key(self, encrypted_key: str) -> Optional[str]:
|
||||
"""
|
||||
Decrypt API key using AES-GCM
|
||||
|
||||
Args:
|
||||
encrypted_key: Encrypted API key string
|
||||
|
||||
Returns:
|
||||
Decrypted API key or None on failure
|
||||
"""
|
||||
try:
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
# Check if MODE_GCM is available (pycryptodome has it, old pycrypto doesn't)
|
||||
if not hasattr(AES, 'MODE_GCM'):
|
||||
# Fallback to base64 decode if old pycrypto is installed
|
||||
try:
|
||||
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
|
||||
except:
|
||||
return None
|
||||
|
||||
# Derive 32-byte AES key
|
||||
key = base64.urlsafe_b64decode(self._get_encryption_key())
|
||||
|
||||
# Decode and split
|
||||
combined = base64.b64decode(encrypted_key.encode('utf-8'))
|
||||
nonce = combined[:12]
|
||||
tag = combined[-16:]
|
||||
ciphertext = combined[12:-16]
|
||||
|
||||
# Decrypt with AES-GCM
|
||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
||||
plaintext = cipher.decrypt_and_verify(ciphertext, tag)
|
||||
|
||||
return plaintext.decode('utf-8')
|
||||
|
||||
except ImportError:
|
||||
# Fallback to base64 decode
|
||||
try:
|
||||
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
|
||||
except:
|
||||
return None
|
||||
except AttributeError:
|
||||
# Old pycrypto doesn't have MODE_GCM, fallback to base64
|
||||
try:
|
||||
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
|
||||
except:
|
||||
return None
|
||||
except Exception as e:
|
||||
# Might be old base64-only format, try decoding
|
||||
try:
|
||||
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
|
||||
except:
|
||||
logger.error(f"Error decrypting API key: {e}")
|
||||
return None
|
||||
|
||||
def save_api_key(self, api_key):
|
||||
"""
|
||||
Save Nexus API key with Fernet encryption
|
||||
|
||||
Args:
|
||||
api_key (str): Plain text API key
|
||||
|
||||
Returns:
|
||||
bool: True if saved successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
if api_key:
|
||||
# Encrypt the API key using Fernet
|
||||
encrypted_key = self._encrypt_api_key(api_key)
|
||||
if not encrypted_key:
|
||||
logger.error("Failed to encrypt API key")
|
||||
return False
|
||||
|
||||
self.settings["nexus_api_key"] = encrypted_key
|
||||
logger.debug("API key encrypted and saved successfully")
|
||||
else:
|
||||
# Clear the API key if empty
|
||||
self.settings["nexus_api_key"] = None
|
||||
logger.debug("API key cleared")
|
||||
|
||||
result = self.save_config()
|
||||
|
||||
# Set restrictive permissions on config file
|
||||
if result:
|
||||
try:
|
||||
os.chmod(self.config_file, 0o600)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not set restrictive permissions on config: {e}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving API key: {e}")
|
||||
return False
|
||||
|
||||
def get_api_key(self):
|
||||
"""
|
||||
Retrieve and decrypt the saved Nexus API key.
|
||||
Always reads fresh from disk.
|
||||
|
||||
Returns:
|
||||
str: Decrypted API key or None if not saved
|
||||
"""
|
||||
try:
|
||||
config = self._read_config_from_disk()
|
||||
encrypted_key = config.get("nexus_api_key")
|
||||
if encrypted_key:
|
||||
# Decrypt the API key
|
||||
decrypted_key = self._decrypt_api_key(encrypted_key)
|
||||
return decrypted_key
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving API key: {e}")
|
||||
return None
|
||||
|
||||
def has_saved_api_key(self):
|
||||
"""
|
||||
Check if an API key is saved in configuration.
|
||||
Always reads fresh from disk.
|
||||
|
||||
Returns:
|
||||
bool: True if API key exists, False otherwise
|
||||
"""
|
||||
config = self._read_config_from_disk()
|
||||
return config.get("nexus_api_key") is not None
|
||||
|
||||
def clear_api_key(self):
|
||||
"""
|
||||
Clear the saved API key from configuration
|
||||
|
||||
Returns:
|
||||
bool: True if cleared successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
self.settings["nexus_api_key"] = None
|
||||
logger.debug("API key cleared from configuration")
|
||||
return self.save_config()
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing API key: {e}")
|
||||
return False
|
||||
def save_resolution(self, resolution):
|
||||
"""
|
||||
Save resolution setting to configuration
|
||||
@@ -589,262 +374,6 @@ class ConfigHandler:
|
||||
logger.error(f"Error clearing resolution: {e}")
|
||||
return False
|
||||
|
||||
def set_default_install_parent_dir(self, path):
|
||||
"""
|
||||
Save the parent directory for modlist installations
|
||||
|
||||
Args:
|
||||
path (str): Parent directory path to save
|
||||
|
||||
Returns:
|
||||
bool: True if saved successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
if path and os.path.exists(path):
|
||||
self.settings["default_install_parent_dir"] = path
|
||||
logger.debug(f"Default install parent directory saved: {path}")
|
||||
return self.save_config()
|
||||
else:
|
||||
logger.warning(f"Invalid or non-existent path for install parent directory: {path}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving install parent directory: {e}")
|
||||
return False
|
||||
|
||||
def get_default_install_parent_dir(self):
|
||||
"""
|
||||
Retrieve the saved parent directory for modlist installations
|
||||
|
||||
Returns:
|
||||
str: Saved parent directory path or None if not saved
|
||||
"""
|
||||
try:
|
||||
path = self.settings.get("default_install_parent_dir")
|
||||
if path and os.path.exists(path):
|
||||
logger.debug(f"Retrieved default install parent directory: {path}")
|
||||
return path
|
||||
else:
|
||||
logger.debug("No valid default install parent directory found")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving install parent directory: {e}")
|
||||
return None
|
||||
|
||||
def set_default_download_parent_dir(self, path):
|
||||
"""
|
||||
Save the parent directory for downloads
|
||||
|
||||
Args:
|
||||
path (str): Parent directory path to save
|
||||
|
||||
Returns:
|
||||
bool: True if saved successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
if path and os.path.exists(path):
|
||||
self.settings["default_download_parent_dir"] = path
|
||||
logger.debug(f"Default download parent directory saved: {path}")
|
||||
return self.save_config()
|
||||
else:
|
||||
logger.warning(f"Invalid or non-existent path for download parent directory: {path}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving download parent directory: {e}")
|
||||
return False
|
||||
|
||||
def get_default_download_parent_dir(self):
|
||||
"""
|
||||
Retrieve the saved parent directory for downloads
|
||||
|
||||
Returns:
|
||||
str: Saved parent directory path or None if not saved
|
||||
"""
|
||||
try:
|
||||
path = self.settings.get("default_download_parent_dir")
|
||||
if path and os.path.exists(path):
|
||||
logger.debug(f"Retrieved default download parent directory: {path}")
|
||||
return path
|
||||
else:
|
||||
logger.debug("No valid default download parent directory found")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving download parent directory: {e}")
|
||||
return None
|
||||
|
||||
def has_saved_install_parent_dir(self):
|
||||
"""
|
||||
Check if a default install parent directory is saved in configuration
|
||||
|
||||
Returns:
|
||||
bool: True if directory exists and is valid, False otherwise
|
||||
"""
|
||||
path = self.settings.get("default_install_parent_dir")
|
||||
return path is not None and os.path.exists(path)
|
||||
|
||||
def has_saved_download_parent_dir(self):
|
||||
"""
|
||||
Check if a default download parent directory is saved in configuration
|
||||
|
||||
Returns:
|
||||
bool: True if directory exists and is valid, False otherwise
|
||||
"""
|
||||
path = self.settings.get("default_download_parent_dir")
|
||||
return path is not None and os.path.exists(path)
|
||||
|
||||
def get_modlist_install_base_dir(self):
|
||||
"""
|
||||
Get the configurable base directory for modlist installations
|
||||
|
||||
Returns:
|
||||
str: Base directory path for modlist installations
|
||||
"""
|
||||
return self.settings.get("modlist_install_base_dir", os.path.expanduser("~/Games"))
|
||||
|
||||
def set_modlist_install_base_dir(self, path):
|
||||
"""
|
||||
Set the configurable base directory for modlist installations
|
||||
|
||||
Args:
|
||||
path (str): Base directory path to save
|
||||
|
||||
Returns:
|
||||
bool: True if saved successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
if path:
|
||||
self.settings["modlist_install_base_dir"] = path
|
||||
logger.debug(f"Modlist install base directory saved: {path}")
|
||||
return self.save_config()
|
||||
else:
|
||||
logger.warning("Invalid path for modlist install base directory")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving modlist install base directory: {e}")
|
||||
return False
|
||||
|
||||
def get_modlist_downloads_base_dir(self):
|
||||
"""
|
||||
Get the configurable base directory for modlist downloads
|
||||
|
||||
Returns:
|
||||
str: Base directory path for modlist downloads
|
||||
"""
|
||||
return self.settings.get("modlist_downloads_base_dir", os.path.expanduser("~/Games/Modlist_Downloads"))
|
||||
|
||||
def set_modlist_downloads_base_dir(self, path):
|
||||
"""
|
||||
Set the configurable base directory for modlist downloads
|
||||
|
||||
Args:
|
||||
path (str): Base directory path to save
|
||||
|
||||
Returns:
|
||||
bool: True if saved successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
if path:
|
||||
self.settings["modlist_downloads_base_dir"] = path
|
||||
logger.debug(f"Modlist downloads base directory saved: {path}")
|
||||
return self.save_config()
|
||||
else:
|
||||
logger.warning("Invalid path for modlist downloads base directory")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving modlist downloads base directory: {e}")
|
||||
return False
|
||||
|
||||
def get_proton_path(self):
|
||||
"""
|
||||
Retrieve the saved Install Proton path from configuration (for jackify-engine).
|
||||
Always reads fresh from disk.
|
||||
|
||||
Returns:
|
||||
str: Saved Install Proton path, or None if not set (indicates auto-detect mode)
|
||||
"""
|
||||
try:
|
||||
config = self._read_config_from_disk()
|
||||
proton_path = config.get("proton_path")
|
||||
# Return None if missing/None/empty string - don't default to "auto"
|
||||
if not proton_path:
|
||||
logger.debug("proton_path not set in config - will use auto-detection")
|
||||
return None
|
||||
logger.debug(f"Retrieved fresh install proton_path from config: {proton_path}")
|
||||
return proton_path
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving install proton_path: {e}")
|
||||
return None
|
||||
|
||||
def get_game_proton_path(self):
|
||||
"""
|
||||
Retrieve the saved Game Proton path from configuration (for game shortcuts).
|
||||
Falls back to install Proton path if game Proton not set.
|
||||
Always reads fresh from disk.
|
||||
|
||||
Returns:
|
||||
str: Saved Game Proton path, Install Proton path, or None if not saved (indicates auto-detect mode)
|
||||
"""
|
||||
try:
|
||||
config = self._read_config_from_disk()
|
||||
game_proton_path = config.get("game_proton_path")
|
||||
|
||||
# If game proton not set or set to same_as_install, use install proton
|
||||
if not game_proton_path or game_proton_path == "same_as_install":
|
||||
game_proton_path = config.get("proton_path") # Returns None if not set
|
||||
|
||||
# Return None if missing/None/empty string
|
||||
if not game_proton_path:
|
||||
logger.debug("game_proton_path not set in config - will use auto-detection")
|
||||
return None
|
||||
logger.debug(f"Retrieved fresh game proton_path from config: {game_proton_path}")
|
||||
return game_proton_path
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving game proton_path: {e}")
|
||||
return "auto"
|
||||
|
||||
def get_proton_version(self):
|
||||
"""
|
||||
Retrieve the saved Proton version from configuration.
|
||||
Always reads fresh from disk.
|
||||
|
||||
Returns:
|
||||
str: Saved Proton version or 'auto' if not saved
|
||||
"""
|
||||
try:
|
||||
config = self._read_config_from_disk()
|
||||
proton_version = config.get("proton_version", "auto")
|
||||
logger.debug(f"Retrieved fresh proton_version from config: {proton_version}")
|
||||
return proton_version
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving proton_version: {e}")
|
||||
return "auto"
|
||||
|
||||
def _auto_detect_proton(self):
|
||||
"""Auto-detect and set best Proton version (includes GE-Proton and Valve Proton)"""
|
||||
try:
|
||||
from .wine_utils import WineUtils
|
||||
best_proton = WineUtils.select_best_proton()
|
||||
|
||||
if best_proton:
|
||||
self.settings["proton_path"] = str(best_proton['path'])
|
||||
self.settings["proton_version"] = best_proton['name']
|
||||
proton_type = best_proton.get('type', 'Unknown')
|
||||
logger.info(f"Auto-detected Proton: {best_proton['name']} ({proton_type})")
|
||||
self.save_config()
|
||||
else:
|
||||
# Set proton_path to None (will appear as null in JSON) so jackify-engine doesn't get invalid path
|
||||
# Code will auto-detect on each run when proton_path is None
|
||||
self.settings["proton_path"] = None
|
||||
self.settings["proton_version"] = None
|
||||
logger.warning("No compatible Proton versions found - proton_path set to null in config.json")
|
||||
logger.info("Jackify will auto-detect Proton on each run until a valid version is found")
|
||||
self.save_config()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-detect Proton: {e}")
|
||||
# Set proton_path to None (will appear as null in JSON)
|
||||
self.settings["proton_path"] = None
|
||||
self.settings["proton_version"] = None
|
||||
logger.warning("proton_path set to null in config.json due to auto-detection failure")
|
||||
self.save_config()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user