Sync from development - prepare for v0.2.0

This commit is contained in:
Omni
2025-12-06 20:09:55 +00:00
parent fe14e4ecfb
commit ce969eba1b
277 changed files with 14059 additions and 3899 deletions

View File

@@ -11,7 +11,9 @@ import logging
import shutil
import re
import base64
import hashlib
from pathlib import Path
from typing import Optional
# Initialize logger
logger = logging.getLogger(__name__)
@@ -40,7 +42,7 @@ class ConfigHandler:
self.config_dir = os.path.expanduser("~/.config/jackify")
self.config_file = os.path.join(self.config_dir, "config.json")
self.settings = {
"version": "0.0.5",
"version": "0.2.0",
"last_selected_modlist": None,
"steam_libraries": [],
"resolution": None,
@@ -52,13 +54,20 @@ class ConfigHandler:
"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, # True = use winetricks (faster), False = use protontricks for all (legacy)
"game_proton_path": None # Proton version for game shortcuts (can be any Proton 9+), separate from install proton
"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
"steam_restart_strategy": "jackify", # "jackify" (default) or "nak_simple"
"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()
# If steam_path is not set, detect it
if not self.settings["steam_path"]:
self.settings["steam_path"] = self._detect_steam_path()
@@ -115,7 +124,10 @@ class ConfigHandler:
return None
def _load_config(self):
"""Load configuration from file"""
"""
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:
@@ -129,6 +141,78 @@ class ConfigHandler:
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
if current_version < "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:
logger.error(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()
@@ -154,8 +238,12 @@ class ConfigHandler:
return False
def get(self, key, default=None):
"""Get a configuration value by key"""
return self.settings.get(key, default)
"""
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"""
@@ -214,48 +302,178 @@ class ConfigHandler:
"""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
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
# 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 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 base64 encoding
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:
# Encode the API key using base64
encoded_key = base64.b64encode(api_key.encode('utf-8')).decode('utf-8')
self.settings["nexus_api_key"] = encoded_key
logger.debug("API key saved successfully")
# 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")
return self.save_config()
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 decode the saved Nexus API key
Always reads fresh from disk to pick up changes from other instances
Retrieve and decrypt the saved Nexus API key.
Always reads fresh from disk.
Returns:
str: Decoded API key or None if not saved
str: Decrypted API key or None if not saved
"""
try:
# Reload config from disk to pick up changes from Settings dialog
self._load_config()
encoded_key = self.settings.get("nexus_api_key")
if encoded_key:
# Decode the base64 encoded key
decoded_key = base64.b64decode(encoded_key.encode('utf-8')).decode('utf-8')
return decoded_key
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}")
@@ -263,15 +481,14 @@ class ConfigHandler:
def has_saved_api_key(self):
"""
Check if an API key is saved in configuration
Always reads fresh from disk to pick up changes from other instances
Check if an API key is saved in configuration.
Always reads fresh from disk.
Returns:
bool: True if API key exists, False otherwise
"""
# Reload config from disk to pick up changes from Settings dialog
self._load_config()
return self.settings.get("nexus_api_key") is not None
config = self._read_config_from_disk()
return config.get("nexus_api_key") is not None
def clear_api_key(self):
"""
@@ -519,16 +736,15 @@ class ConfigHandler:
def get_proton_path(self):
"""
Retrieve the saved Install Proton path from configuration (for jackify-engine)
Always reads fresh from disk to pick up changes from Settings dialog
Retrieve the saved Install Proton path from configuration (for jackify-engine).
Always reads fresh from disk.
Returns:
str: Saved Install Proton path or 'auto' if not saved
"""
try:
# Reload config from disk to pick up changes from Settings dialog
self._load_config()
proton_path = self.settings.get("proton_path", "auto")
config = self._read_config_from_disk()
proton_path = config.get("proton_path", "auto")
logger.debug(f"Retrieved fresh install proton_path from config: {proton_path}")
return proton_path
except Exception as e:
@@ -537,21 +753,20 @@ class ConfigHandler:
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 to pick up changes from Settings dialog
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 'auto' if not saved
"""
try:
# Reload config from disk to pick up changes from Settings dialog
self._load_config()
game_proton_path = self.settings.get("game_proton_path")
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 = self.settings.get("proton_path", "auto")
game_proton_path = config.get("proton_path", "auto")
logger.debug(f"Retrieved fresh game proton_path from config: {game_proton_path}")
return game_proton_path
@@ -561,16 +776,15 @@ class ConfigHandler:
def get_proton_version(self):
"""
Retrieve the saved Proton version from configuration
Always reads fresh from disk to pick up changes from Settings dialog
Retrieve the saved Proton version from configuration.
Always reads fresh from disk.
Returns:
str: Saved Proton version or 'auto' if not saved
"""
try:
# Reload config from disk to pick up changes from Settings dialog
self._load_config()
proton_version = self.settings.get("proton_version", "auto")
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:

File diff suppressed because it is too large Load Diff

View File

@@ -863,60 +863,6 @@ class MenuHandler:
self.logger.debug("_clear_screen: Clearing screen for POSIX by printing 100 newlines.")
print("\n" * 100, flush=True)
def show_hoolamike_menu(self, cli_instance):
"""Show the Hoolamike Modlist Management menu"""
if not hasattr(cli_instance, 'hoolamike_handler') or cli_instance.hoolamike_handler is None:
try:
from .hoolamike_handler import HoolamikeHandler
cli_instance.hoolamike_handler = HoolamikeHandler(
steamdeck=getattr(cli_instance, 'steamdeck', False),
verbose=getattr(cli_instance, 'verbose', False),
filesystem_handler=getattr(cli_instance, 'filesystem_handler', None),
config_handler=getattr(cli_instance, 'config_handler', None),
menu_handler=self
)
except Exception as e:
self.logger.error(f"Failed to initialize Hoolamike features: {e}", exc_info=True)
print(f"{COLOR_ERROR}Error: Failed to initialize Hoolamike features. Check logs.{COLOR_RESET}")
input("\nPress Enter to return to the main menu...")
return # Exit this menu if handler fails
while True:
self._clear_screen()
# Banner display handled by frontend
# Use print_section_header for consistency if available, otherwise manual with COLOR_SELECTION
if hasattr(self, 'print_section_header'): # Check if method exists (it's from ui_utils)
print_section_header("Hoolamike Modlist Management")
else: # Fallback if not imported or available directly on self
print(f"{COLOR_SELECTION}Hoolamike Modlist Management{COLOR_RESET}")
print(f"{COLOR_SELECTION}{'-'*30}{COLOR_RESET}")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Install or Update Hoolamike App")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Install Modlist (Nexus Premium)")
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Install Modlist (Non-Premium) {COLOR_DISABLED}(Not Implemented){COLOR_RESET}")
print(f"{COLOR_SELECTION}4.{COLOR_RESET} Install Tale of Two Wastelands (TTW)")
print(f"{COLOR_SELECTION}5.{COLOR_RESET} Edit Hoolamike Configuration")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-5): {COLOR_RESET}").strip()
if selection.lower() == 'q': # Allow 'q' to re-display menu
continue
if selection == "1":
cli_instance.hoolamike_handler.install_update_hoolamike()
elif selection == "2":
cli_instance.hoolamike_handler.install_modlist(premium=True)
elif selection == "3":
print(f"{COLOR_INFO}Install Modlist (Non-Premium) is not yet implemented.{COLOR_RESET}")
input("\nPress Enter to return to the Hoolamike menu...")
elif selection == "4":
cli_instance.hoolamike_handler.install_ttw()
elif selection == "5":
cli_instance.hoolamike_handler.edit_hoolamike_config()
elif selection == "0":
break
else:
print("Invalid selection. Please try again.")
time.sleep(1)

View File

@@ -571,15 +571,19 @@ class ModlistHandler:
status_callback (callable, optional): A function to call with status updates during configuration.
manual_steps_completed (bool): If True, skip the manual steps prompt (used for new modlist flow).
"""
# Store status_callback for Configuration Summary
self._current_status_callback = status_callback
self.logger.info("Executing configuration steps...")
# Ensure required context is set
if not all([self.modlist_dir, self.appid, self.game_var, self.steamdeck is not None]):
self.logger.error("Cannot execute configuration steps: Missing required context (modlist_dir, appid, game_var, steamdeck status).")
print("Error: Missing required information to start configuration.")
try:
# Store status_callback for Configuration Summary
self._current_status_callback = status_callback
self.logger.info("Executing configuration steps...")
# Ensure required context is set
if not all([self.modlist_dir, self.appid, self.game_var, self.steamdeck is not None]):
self.logger.error("Cannot execute configuration steps: Missing required context (modlist_dir, appid, game_var, steamdeck status).")
print("Error: Missing required information to start configuration.")
return False
except Exception as e:
self.logger.error(f"Exception in _execute_configuration_steps initialization: {e}", exc_info=True)
return False
# Step 1: Set protontricks permissions
@@ -706,15 +710,18 @@ class ModlistHandler:
target_appid = self.appid
# Use user's preferred component installation method (respects settings toggle)
self.logger.debug(f"Getting WINEPREFIX for AppID {target_appid}...")
wineprefix = self.protontricks_handler.get_wine_prefix_path(target_appid)
if not wineprefix:
self.logger.error("Failed to get WINEPREFIX path for component installation.")
print("Error: Could not determine wine prefix location.")
return False
self.logger.debug(f"WINEPREFIX obtained: {wineprefix}")
# Use the winetricks handler which respects the user's toggle setting
try:
self.logger.info("Installing Wine components using user's preferred method...")
self.logger.debug(f"Calling winetricks_handler.install_wine_components with wineprefix={wineprefix}, game_var={self.game_var_full}, components={components}")
success = self.winetricks_handler.install_wine_components(wineprefix, self.game_var_full, specific_components=components)
if success:
self.logger.info("Wine component installation completed successfully")
@@ -920,16 +927,25 @@ class ModlistHandler:
if self.steam_library and self.game_var_full:
vanilla_game_dir = str(Path(self.steam_library) / "steamapps" / "common" / self.game_var_full)
if not self.path_handler.create_dxvk_conf(
dxvk_created = self.path_handler.create_dxvk_conf(
modlist_dir=self.modlist_dir,
modlist_sdcard=self.modlist_sdcard,
steam_library=str(self.steam_library) if self.steam_library else None, # Pass as string or None
basegame_sdcard=self.basegame_sdcard,
game_var_full=self.game_var_full,
vanilla_game_dir=vanilla_game_dir
):
self.logger.warning("Failed to create dxvk.conf file.")
print("Warning: Failed to create dxvk.conf file.")
vanilla_game_dir=vanilla_game_dir,
stock_game_path=self.stock_game_path
)
dxvk_verified = self.path_handler.verify_dxvk_conf_exists(
modlist_dir=self.modlist_dir,
steam_library=str(self.steam_library) if self.steam_library else None,
game_var_full=self.game_var_full,
vanilla_game_dir=vanilla_game_dir,
stock_game_path=self.stock_game_path
)
if not dxvk_created or not dxvk_verified:
self.logger.warning("DXVK configuration file is missing or incomplete after post-install steps.")
print("Warning: Failed to verify dxvk.conf file (required for AMD GPUs).")
self.logger.info("Step 10: Creating dxvk.conf... Done")
# Step 11a: Small Tasks - Delete Incompatible Plugins

View File

@@ -49,7 +49,7 @@ logger = logging.getLogger(__name__) # Standard logger init
# Helper function to get path to jackify-install-engine
def get_jackify_engine_path():
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
# Running in a PyInstaller bundle
# Running inside the bundled AppImage (frozen)
# Engine is expected at <bundle_root>/jackify/engine/jackify-engine
return os.path.join(sys._MEIPASS, 'jackify', 'engine', 'jackify-engine')
else:
@@ -408,51 +408,76 @@ class ModlistInstallCLI:
self.context['download_dir'] = download_dir_path
self.logger.debug(f"Download directory context set to: {self.context['download_dir']}")
# 5. Prompt for Nexus API key (skip if in context)
# 5. Get Nexus authentication (OAuth or API key)
if 'nexus_api_key' not in self.context:
from jackify.backend.services.api_key_service import APIKeyService
api_key_service = APIKeyService()
saved_key = api_key_service.get_saved_api_key()
api_key = None
if saved_key:
print("\n" + "-" * 28)
print(f"{COLOR_INFO}A Nexus API Key is already saved.{COLOR_RESET}")
use_saved = input(f"{COLOR_PROMPT}Use the saved API key? [Y/n]: {COLOR_RESET}").strip().lower()
if use_saved in ('', 'y', 'yes'):
api_key = saved_key
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
# Get current auth status
authenticated, method, username = auth_service.get_auth_status()
if authenticated:
# Already authenticated - use existing auth
if method == 'oauth':
print("\n" + "-" * 28)
print(f"{COLOR_SUCCESS}Nexus Authentication: Authorized via OAuth{COLOR_RESET}")
if username:
print(f"{COLOR_INFO}Logged in as: {username}{COLOR_RESET}")
elif method == 'api_key':
print("\n" + "-" * 28)
print(f"{COLOR_INFO}Nexus Authentication: Using API Key (Legacy){COLOR_RESET}")
# Get valid token/key
api_key = auth_service.ensure_valid_auth()
if api_key:
self.context['nexus_api_key'] = api_key
else:
new_key = input(f"{COLOR_PROMPT}Enter a new Nexus API Key (or press Enter to keep the saved one): {COLOR_RESET}").strip()
if new_key:
api_key = new_key
replace = input(f"{COLOR_PROMPT}Replace the saved key with this one? [y/N]: {COLOR_RESET}").strip().lower()
if replace == 'y':
if api_key_service.save_api_key(api_key):
print(f"{COLOR_SUCCESS}API key saved successfully.{COLOR_RESET}")
else:
print(f"{COLOR_WARNING}Failed to save API key. Using for this session only.{COLOR_RESET}")
# Auth expired or invalid - prompt to set up
print(f"\n{COLOR_WARNING}Your authentication has expired or is invalid.{COLOR_RESET}")
authenticated = False
if not authenticated:
# Not authenticated - offer to set up OAuth
print("\n" + "-" * 28)
print(f"{COLOR_WARNING}Nexus Mods authentication is required for downloading mods.{COLOR_RESET}")
print(f"\n{COLOR_PROMPT}Would you like to authorize with Nexus now?{COLOR_RESET}")
print(f"{COLOR_INFO}This will open your browser for secure OAuth authorization.{COLOR_RESET}")
authorize = input(f"{COLOR_PROMPT}Authorize now? [Y/n]: {COLOR_RESET}").strip().lower()
if authorize in ('', 'y', 'yes'):
# Launch OAuth authorization
print(f"\n{COLOR_INFO}Starting OAuth authorization...{COLOR_RESET}")
print(f"{COLOR_WARNING}Your browser will open shortly.{COLOR_RESET}")
print(f"{COLOR_INFO}Note: Your browser may ask permission to open 'xdg-open' or{COLOR_RESET}")
print(f"{COLOR_INFO}Jackify's protocol handler - please click 'Open' or 'Allow'.{COLOR_RESET}")
def show_message(msg):
print(f"\n{COLOR_INFO}{msg}{COLOR_RESET}")
success = auth_service.authorize_oauth(show_browser_message_callback=show_message)
if success:
print(f"\n{COLOR_SUCCESS}OAuth authorization successful!{COLOR_RESET}")
_, _, username = auth_service.get_auth_status()
if username:
print(f"{COLOR_INFO}Authorized as: {username}{COLOR_RESET}")
api_key = auth_service.ensure_valid_auth()
if api_key:
self.context['nexus_api_key'] = api_key
else:
print(f"{COLOR_INFO}Using new key for this session only. Saved key unchanged.{COLOR_RESET}")
print(f"{COLOR_ERROR}Failed to retrieve auth token after authorization.{COLOR_RESET}")
return None
else:
api_key = saved_key
else:
print("\n" + "-" * 28)
print(f"{COLOR_INFO}A Nexus Mods API key is required for downloading mods.{COLOR_RESET}")
print(f"{COLOR_INFO}You can get your personal key at: {COLOR_SELECTION}https://www.nexusmods.com/users/myaccount?tab=api{COLOR_RESET}")
print(f"{COLOR_WARNING}Your API Key is NOT saved locally. It is used only for this session unless you choose to save it.{COLOR_RESET}")
api_key = input(f"{COLOR_PROMPT}Enter Nexus API Key (or 'q' to cancel): {COLOR_RESET}").strip()
if not api_key or api_key.lower() == 'q':
self.logger.info("User cancelled or provided no API key.")
return None
save = input(f"{COLOR_PROMPT}Would you like to save this API key for future use? [y/N]: {COLOR_RESET}").strip().lower()
if save == 'y':
if api_key_service.save_api_key(api_key):
print(f"{COLOR_SUCCESS}API key saved successfully.{COLOR_RESET}")
else:
print(f"{COLOR_WARNING}Failed to save API key. Using for this session only.{COLOR_RESET}")
print(f"\n{COLOR_ERROR}OAuth authorization failed.{COLOR_RESET}")
return None
else:
print(f"{COLOR_INFO}Using API key for this session only. It will not be saved.{COLOR_RESET}")
self.context['nexus_api_key'] = api_key
self.logger.debug(f"NEXUS_API_KEY is set in environment for engine (presence check).")
# User declined OAuth - cancelled
print(f"\n{COLOR_INFO}Authorization required to proceed. Installation cancelled.{COLOR_RESET}")
self.logger.info("User declined Nexus authorization.")
return None
self.logger.debug(f"Nexus authentication configured for engine.")
# Display summary and confirm
self._display_summary() # Ensure this method exists or implement it
@@ -501,11 +526,23 @@ class ModlistInstallCLI:
if isinstance(download_dir_display, tuple):
download_dir_display = download_dir_display[0] # Get the Path object from (Path, bool)
print(f"Download Directory: {download_dir_display}")
if self.context.get('nexus_api_key'):
print(f"Nexus API Key: [SET]")
# Show authentication method
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
authenticated, method, username = auth_service.get_auth_status()
if method == 'oauth':
auth_display = f"Nexus Authentication: OAuth"
if username:
auth_display += f" ({username})"
elif method == 'api_key':
auth_display = "Nexus Authentication: API Key (Legacy)"
else:
print(f"Nexus API Key: [NOT SET - WILL LIKELY FAIL]")
# Should never reach here since we validate auth before getting to summary
auth_display = "Nexus Authentication: Unknown"
print(auth_display)
print(f"{COLOR_INFO}----------------------------------------{COLOR_RESET}")
def configuration_phase(self):
@@ -597,8 +634,8 @@ class ModlistInstallCLI:
# --- End Patch ---
# Build command
cmd = [engine_path, 'install']
cmd = [engine_path, 'install', '--show-file-progress']
# Check for debug mode and pass --debug to engine if needed
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
@@ -606,7 +643,13 @@ class ModlistInstallCLI:
if debug_mode:
cmd.append('--debug')
self.logger.info("Debug mode enabled in config - passing --debug flag to jackify-engine")
# Check GPU setting and add --no-gpu flag if disabled
gpu_enabled = config_handler.get('enable_gpu_texture_conversion', True)
if not gpu_enabled:
cmd.append('--no-gpu')
self.logger.info("GPU texture conversion disabled - passing --no-gpu flag to jackify-engine")
# Determine if this is a local .wabbajack file or an online modlist
modlist_value = self.context.get('modlist_value')
machineid = self.context.get('machineid')
@@ -667,8 +710,10 @@ class ModlistInstallCLI:
else:
self.logger.warning(f"File descriptor limit: {message}")
# Popen now inherits the modified os.environ because env=None
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=None, cwd=engine_dir)
# Use cleaned environment to prevent AppImage variable inheritance
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
clean_env = get_clean_subprocess_env()
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=clean_env, cwd=engine_dir)
# Start performance monitoring for the engine process
# Adjust monitoring based on debug mode
@@ -1101,11 +1146,23 @@ class ModlistInstallCLI:
if isinstance(download_dir_display, tuple):
download_dir_display = download_dir_display[0] # Get the Path object from (Path, bool)
print(f"Download Directory: {download_dir_display}")
if self.context.get('nexus_api_key'):
print(f"Nexus API Key: [SET]")
# Show authentication method
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
authenticated, method, username = auth_service.get_auth_status()
if method == 'oauth':
auth_display = f"Nexus Authentication: OAuth"
if username:
auth_display += f" ({username})"
elif method == 'api_key':
auth_display = "Nexus Authentication: API Key (Legacy)"
else:
print(f"Nexus API Key: [NOT SET - WILL LIKELY FAIL]")
# Should never reach here since we validate auth before getting to summary
auth_display = "Nexus Authentication: Unknown"
print(auth_display)
print(f"{COLOR_INFO}----------------------------------------{COLOR_RESET}")
def _enhance_nexus_error(self, line: str) -> str:
@@ -1234,52 +1291,65 @@ class ModlistInstallCLI:
print(f"\n{COLOR_INFO}Starting TTW installation workflow...{COLOR_RESET}")
# Import TTW installation handler
from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
from jackify.backend.models.configuration import SystemInfo
from pathlib import Path
system_info = SystemInfo()
hoolamike_handler = HoolamikeHandler(system_info)
ttw_installer_handler = TTWInstallerHandler(
steamdeck=system_info.is_steamdeck if hasattr(system_info, 'is_steamdeck') else False,
verbose=self.verbose if hasattr(self, 'verbose') else False,
filesystem_handler=self.filesystem_handler if hasattr(self, 'filesystem_handler') else None,
config_handler=self.config_handler if hasattr(self, 'config_handler') else None
)
# Check if Hoolamike is installed
is_installed, installed_version = hoolamike_handler.check_installation_status()
# Check if TTW_Linux_Installer is installed
ttw_installer_handler._check_installation()
if not is_installed:
print(f"{COLOR_INFO}Hoolamike (TTW installer) is not installed.{COLOR_RESET}")
user_input = input(f"{COLOR_PROMPT}Install Hoolamike? (yes/no): {COLOR_RESET}").strip().lower()
if not ttw_installer_handler.ttw_installer_installed:
print(f"{COLOR_INFO}TTW_Linux_Installer is not installed.{COLOR_RESET}")
user_input = input(f"{COLOR_PROMPT}Install TTW_Linux_Installer? (yes/no): {COLOR_RESET}").strip().lower()
if user_input not in ['yes', 'y']:
print(f"{COLOR_INFO}TTW installation cancelled.{COLOR_RESET}")
return
# Install Hoolamike
print(f"{COLOR_INFO}Installing Hoolamike...{COLOR_RESET}")
success, message = hoolamike_handler.install_hoolamike()
# Install TTW_Linux_Installer
print(f"{COLOR_INFO}Installing TTW_Linux_Installer...{COLOR_RESET}")
success, message = ttw_installer_handler.install_ttw_installer()
if not success:
print(f"{COLOR_ERROR}Failed to install Hoolamike: {message}{COLOR_RESET}")
print(f"{COLOR_ERROR}Failed to install TTW_Linux_Installer: {message}{COLOR_RESET}")
return
print(f"{COLOR_INFO}Hoolamike installed successfully.{COLOR_RESET}")
print(f"{COLOR_INFO}TTW_Linux_Installer installed successfully.{COLOR_RESET}")
# Get Hoolamike MPI path
mpi_path = hoolamike_handler.get_mpi_path()
if not mpi_path or not os.path.exists(mpi_path):
print(f"{COLOR_ERROR}Hoolamike MPI file not found at: {mpi_path}{COLOR_RESET}")
# Prompt for TTW .mpi file
print(f"\n{COLOR_PROMPT}TTW Installer File (.mpi){COLOR_RESET}")
mpi_path = input(f"{COLOR_PROMPT}Path to TTW .mpi file: {COLOR_RESET}").strip()
if not mpi_path:
print(f"{COLOR_WARNING}No .mpi file specified. Cancelling.{COLOR_RESET}")
return
mpi_path = Path(mpi_path).expanduser()
if not mpi_path.exists() or not mpi_path.is_file():
print(f"{COLOR_ERROR}TTW .mpi file not found: {mpi_path}{COLOR_RESET}")
return
# Prompt for TTW installation directory
print(f"\n{COLOR_PROMPT}TTW Installation Directory{COLOR_RESET}")
print(f"Default: {os.path.join(install_dir, 'TTW')}")
default_ttw_dir = os.path.join(install_dir, 'TTW')
print(f"Default: {default_ttw_dir}")
ttw_install_dir = input(f"{COLOR_PROMPT}TTW install directory (Enter for default): {COLOR_RESET}").strip()
if not ttw_install_dir:
ttw_install_dir = os.path.join(install_dir, "TTW")
ttw_install_dir = default_ttw_dir
# Run Hoolamike installation
print(f"\n{COLOR_INFO}Installing TTW using Hoolamike...{COLOR_RESET}")
# Run TTW installation
print(f"\n{COLOR_INFO}Installing TTW using TTW_Linux_Installer...{COLOR_RESET}")
print(f"{COLOR_INFO}This may take a while (15-30 minutes depending on your system).{COLOR_RESET}")
success = hoolamike_handler.run_hoolamike_install(mpi_path, ttw_install_dir)
success, message = ttw_installer_handler.install_ttw_backend(Path(mpi_path), Path(ttw_install_dir))
if success:
print(f"\n{COLOR_INFO}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
@@ -1289,6 +1359,7 @@ class ModlistInstallCLI:
print(f"The modlist '{modlist_name}' is now ready to use with TTW.")
else:
print(f"\n{COLOR_ERROR}TTW installation failed. Check the logs for details.{COLOR_RESET}")
print(f"{COLOR_ERROR}Error: {message}{COLOR_RESET}")
except Exception as e:
self.logger.error(f"Error during TTW installation: {e}", exc_info=True)

View File

@@ -0,0 +1,434 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
OAuth Token Handler
Handles encrypted storage and retrieval of OAuth tokens
"""
import os
import json
import base64
import hashlib
import logging
import time
from typing import Optional, Dict
from pathlib import Path
logger = logging.getLogger(__name__)
class OAuthTokenHandler:
"""
Handles OAuth token storage with simple encryption
Stores tokens in ~/.config/jackify/nexus-oauth.json
"""
def __init__(self, config_dir: Optional[str] = None):
"""
Initialize token handler
Args:
config_dir: Optional custom config directory (defaults to ~/.config/jackify)
"""
if config_dir:
self.config_dir = Path(config_dir)
else:
self.config_dir = Path.home() / ".config" / "jackify"
self.token_file = self.config_dir / "nexus-oauth.json"
# Ensure config directory exists
self.config_dir.mkdir(parents=True, exist_ok=True)
# Generate encryption key based on machine-specific data
self._encryption_key = self._generate_encryption_key()
def _generate_encryption_key(self) -> bytes:
"""
Generate encryption key based on machine-specific data using Fernet
Uses hostname + username + machine ID as key material, similar to DPAPI approach.
This provides proper symmetric encryption while remaining machine-specific.
Returns:
Fernet-compatible 32-byte encryption key
"""
import socket
import getpass
try:
hostname = socket.gethostname()
username = getpass.getuser()
# Try to get machine ID for additional entropy
machine_id = None
try:
# Linux machine-id
with open('/etc/machine-id', 'r') as f:
machine_id = f.read().strip()
except:
try:
# Alternative locations
with open('/var/lib/dbus/machine-id', 'r') as f:
machine_id = f.read().strip()
except:
pass
# Combine multiple sources of machine-specific data
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 32-byte key using SHA256 for Fernet
# Fernet requires base64-encoded 32-byte key
key_bytes = hashlib.sha256(key_material.encode('utf-8')).digest()
return base64.urlsafe_b64encode(key_bytes)
def _encrypt_data(self, data: str) -> str:
"""
Encrypt data using AES-GCM (authenticated encryption)
Uses pycryptodome for cross-platform compatibility.
AES-GCM provides authenticated encryption similar to Fernet.
Args:
data: Plain text data
Returns:
Encrypted data as base64 string (nonce:ciphertext:tag format)
"""
try:
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
# Derive 32-byte AES key from encryption_key (which is base64-encoded)
key = base64.urlsafe_b64decode(self._encryption_key)
# Generate random nonce (12 bytes for GCM)
nonce = get_random_bytes(12)
# Create AES-GCM cipher
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
# Encrypt and get authentication tag
data_bytes = data.encode('utf-8')
ciphertext, tag = cipher.encrypt_and_digest(data_bytes)
# Combine nonce:ciphertext:tag and base64 encode
combined = nonce + ciphertext + tag
return base64.b64encode(combined).decode('utf-8')
except ImportError:
logger.error("pycryptodome package not available for token encryption")
return ""
except Exception as e:
logger.error(f"Failed to encrypt data: {e}")
return ""
def _decrypt_data(self, encrypted_data: str) -> Optional[str]:
"""
Decrypt data using AES-GCM (authenticated encryption)
Args:
encrypted_data: Encrypted data string (base64-encoded nonce:ciphertext:tag)
Returns:
Decrypted plain text or None on failure
"""
try:
from Crypto.Cipher import AES
# Derive 32-byte AES key from encryption_key
key = base64.urlsafe_b64decode(self._encryption_key)
# Decode base64 and split nonce:ciphertext:tag
combined = base64.b64decode(encrypted_data.encode('utf-8'))
nonce = combined[:12]
tag = combined[-16:]
ciphertext = combined[12:-16]
# Create AES-GCM cipher
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
# Decrypt and verify authentication tag
plaintext = cipher.decrypt_and_verify(ciphertext, tag)
return plaintext.decode('utf-8')
except ImportError:
logger.error("pycryptodome package not available for token decryption")
return None
except Exception as e:
logger.error(f"Failed to decrypt data: {e}")
return None
def save_token(self, token_data: Dict) -> bool:
"""
Save OAuth token to encrypted file with proper permissions
Args:
token_data: Token data dict from OAuth response
Returns:
True if saved successfully
"""
try:
# Add timestamp for tracking
token_data['_saved_at'] = int(time.time())
# Convert to JSON
json_data = json.dumps(token_data, indent=2)
# Encrypt using Fernet
encrypted = self._encrypt_data(json_data)
if not encrypted:
logger.error("Encryption failed, cannot save token")
return False
# Save to file with restricted permissions
# Write to temp file first, then move (atomic operation)
import tempfile
fd, temp_path = tempfile.mkstemp(dir=self.config_dir, prefix='.oauth_tmp_')
try:
with os.fdopen(fd, 'w') as f:
json.dump({'encrypted_data': encrypted}, f, indent=2)
# Set restrictive permissions (owner read/write only)
os.chmod(temp_path, 0o600)
# Atomic move
os.replace(temp_path, self.token_file)
logger.info(f"Saved encrypted OAuth token to {self.token_file}")
return True
except Exception as e:
# Clean up temp file on error
try:
os.unlink(temp_path)
except:
pass
raise e
except Exception as e:
logger.error(f"Failed to save OAuth token: {e}")
return False
def load_token(self) -> Optional[Dict]:
"""
Load OAuth token from encrypted file
Returns:
Token data dict or None if not found or invalid
"""
if not self.token_file.exists():
logger.debug("No OAuth token file found")
return None
try:
# Load encrypted data
with open(self.token_file, 'r') as f:
data = json.load(f)
encrypted = data.get('encrypted_data')
if not encrypted:
logger.error("Token file missing encrypted_data field")
return None
# Decrypt
decrypted = self._decrypt_data(encrypted)
if not decrypted:
logger.error("Failed to decrypt token data")
return None
# Parse JSON
token_data = json.loads(decrypted)
logger.debug("Successfully loaded OAuth token")
return token_data
except json.JSONDecodeError as e:
logger.error(f"Token file contains invalid JSON: {e}")
return None
except Exception as e:
logger.error(f"Failed to load OAuth token: {e}")
return None
def delete_token(self) -> bool:
"""
Delete OAuth token file
Returns:
True if deleted successfully
"""
try:
if self.token_file.exists():
self.token_file.unlink()
logger.info("Deleted OAuth token file")
return True
else:
logger.debug("No OAuth token file to delete")
return False
except Exception as e:
logger.error(f"Failed to delete OAuth token: {e}")
return False
def has_token(self) -> bool:
"""
Check if OAuth token file exists
Returns:
True if token file exists
"""
return self.token_file.exists()
def is_token_expired(self, token_data: Optional[Dict] = None, buffer_minutes: int = 5) -> bool:
"""
Check if token is expired or close to expiring
Args:
token_data: Optional token data dict (loads from file if not provided)
buffer_minutes: Minutes before expiry to consider token expired (default 5)
Returns:
True if token is expired or will expire within buffer_minutes
"""
if token_data is None:
token_data = self.load_token()
if not token_data:
return True
# Extract OAuth data if nested
oauth_data = token_data.get('oauth', token_data)
# Get expiry information
expires_in = oauth_data.get('expires_in')
saved_at = token_data.get('_saved_at')
if not expires_in or not saved_at:
logger.debug("Token missing expiry information, assuming valid")
return False # Assume token is valid if no expiry info
# Calculate expiry time
expires_at = saved_at + expires_in
buffer_seconds = buffer_minutes * 60
now = int(time.time())
# Check if expired or within buffer
is_expired = (expires_at - buffer_seconds) < now
if is_expired:
remaining = expires_at - now
if remaining < 0:
logger.debug(f"Token expired {-remaining} seconds ago")
else:
logger.debug(f"Token expires in {remaining} seconds (within buffer)")
return is_expired
def get_access_token(self) -> Optional[str]:
"""
Get access token from storage
Returns:
Access token string or None if not found or expired
"""
token_data = self.load_token()
if not token_data:
return None
# Check if expired
if self.is_token_expired(token_data):
logger.debug("Stored token is expired")
return None
# Extract access token from OAuth structure
oauth_data = token_data.get('oauth', token_data)
access_token = oauth_data.get('access_token')
if not access_token:
logger.error("Token data missing access_token field")
return None
return access_token
def get_refresh_token(self) -> Optional[str]:
"""
Get refresh token from storage
Returns:
Refresh token string or None if not found
"""
token_data = self.load_token()
if not token_data:
return None
# Extract refresh token from OAuth structure
oauth_data = token_data.get('oauth', token_data)
refresh_token = oauth_data.get('refresh_token')
return refresh_token
def get_token_info(self) -> Dict:
"""
Get diagnostic information about current token
Returns:
Dict with token status information
"""
token_data = self.load_token()
if not token_data:
return {
'has_token': False,
'error': 'No token file found'
}
oauth_data = token_data.get('oauth', token_data)
expires_in = oauth_data.get('expires_in')
saved_at = token_data.get('_saved_at')
# Check if refresh token is likely expired (30 days since last auth)
# Nexus doesn't provide refresh token expiry, so we estimate conservatively
REFRESH_TOKEN_LIFETIME_DAYS = 30
now = int(time.time())
refresh_token_age_days = (now - saved_at) / 86400 if saved_at else 0
refresh_token_likely_expired = refresh_token_age_days > REFRESH_TOKEN_LIFETIME_DAYS
if expires_in and saved_at:
expires_at = saved_at + expires_in
remaining_seconds = expires_at - now
return {
'has_token': True,
'has_refresh_token': bool(oauth_data.get('refresh_token')),
'expires_in_seconds': remaining_seconds,
'expires_in_minutes': remaining_seconds / 60,
'expires_in_hours': remaining_seconds / 3600,
'is_expired': remaining_seconds < 0,
'expires_soon_5min': remaining_seconds < 300,
'expires_soon_15min': remaining_seconds < 900,
'saved_at': saved_at,
'expires_at': expires_at,
'refresh_token_age_days': refresh_token_age_days,
'refresh_token_likely_expired': refresh_token_likely_expired,
}
else:
return {
'has_token': True,
'has_refresh_token': bool(oauth_data.get('refresh_token')),
'refresh_token_age_days': refresh_token_age_days,
'refresh_token_likely_expired': refresh_token_likely_expired,
'error': 'Token missing expiry information'
}

View File

@@ -12,6 +12,7 @@ import shutil
from pathlib import Path
from typing import Optional, Union, Dict, Any, List, Tuple
from datetime import datetime
import vdf
# Initialize logger
logger = logging.getLogger(__name__)
@@ -258,7 +259,7 @@ class PathHandler:
return False
@staticmethod
def create_dxvk_conf(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full, vanilla_game_dir=None):
def create_dxvk_conf(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full, vanilla_game_dir=None, stock_game_path=None):
"""
Create dxvk.conf file in the appropriate location
@@ -269,6 +270,7 @@ class PathHandler:
basegame_sdcard (bool): Whether the base game is on an SD card
game_var_full (str): Full name of the game (e.g., "Skyrim Special Edition")
vanilla_game_dir (str): Optional path to vanilla game directory for fallback
stock_game_path (str): Direct path to detected stock game directory (if available)
Returns:
bool: True on success, False on failure
@@ -276,49 +278,45 @@ class PathHandler:
try:
logger.info("Creating dxvk.conf file...")
# Determine the location for dxvk.conf
dxvk_conf_path = None
candidate_dirs = PathHandler._build_dxvk_candidate_dirs(
modlist_dir=modlist_dir,
stock_game_path=stock_game_path,
steam_library=steam_library,
game_var_full=game_var_full,
vanilla_game_dir=vanilla_game_dir
)
# Check for common stock game directories first, then vanilla as fallback
stock_game_paths = [
os.path.join(modlist_dir, "Stock Game"),
os.path.join(modlist_dir, "Game Root"),
os.path.join(modlist_dir, "STOCK GAME"),
os.path.join(modlist_dir, "Stock Game Folder"),
os.path.join(modlist_dir, "Stock Folder"),
os.path.join(modlist_dir, "Skyrim Stock"),
os.path.join(modlist_dir, "root", "Skyrim Special Edition")
]
if not candidate_dirs:
logger.error("Could not determine location for dxvk.conf (no candidate directories found)")
return False
# Add vanilla game directory as fallback if steam_library and game_var_full are provided
if steam_library and game_var_full:
stock_game_paths.append(os.path.join(steam_library, "steamapps", "common", game_var_full))
for path in stock_game_paths:
if os.path.exists(path):
dxvk_conf_path = os.path.join(path, "dxvk.conf")
target_dir = None
for directory in candidate_dirs:
if directory.is_dir():
target_dir = directory
break
if not dxvk_conf_path:
# Fallback: Try vanilla game directory if provided
if vanilla_game_dir and os.path.exists(vanilla_game_dir):
logger.info(f"Attempting fallback to vanilla game directory: {vanilla_game_dir}")
dxvk_conf_path = os.path.join(vanilla_game_dir, "dxvk.conf")
logger.info(f"Using vanilla game directory for dxvk.conf: {dxvk_conf_path}")
if target_dir is None:
fallback_dir = Path(modlist_dir) if modlist_dir and Path(modlist_dir).is_dir() else None
if fallback_dir:
logger.warning(f"No stock/vanilla directories found; falling back to modlist directory: {fallback_dir}")
target_dir = fallback_dir
else:
logger.error("Could not determine location for dxvk.conf")
logger.error("All candidate directories for dxvk.conf are missing.")
return False
dxvk_conf_path = target_dir / "dxvk.conf"
# The required line that Jackify needs
required_line = "dxvk.enableGraphicsPipelineLibrary = False"
# Check if dxvk.conf already exists
if os.path.exists(dxvk_conf_path):
if dxvk_conf_path.exists():
logger.info(f"Found existing dxvk.conf at {dxvk_conf_path}")
# Read existing content
try:
with open(dxvk_conf_path, 'r') as f:
with open(dxvk_conf_path, 'r', encoding='utf-8') as f:
existing_content = f.read().strip()
# Check if our required line is already present
@@ -339,7 +337,7 @@ class PathHandler:
updated_content = required_line + '\n'
logger.info("Adding required DXVK setting to empty file")
with open(dxvk_conf_path, 'w') as f:
with open(dxvk_conf_path, 'w', encoding='utf-8') as f:
f.write(updated_content)
logger.info(f"dxvk.conf updated successfully at {dxvk_conf_path}")
@@ -353,7 +351,8 @@ class PathHandler:
# Create new dxvk.conf file (original behavior)
dxvk_conf_content = required_line + '\n'
with open(dxvk_conf_path, 'w') as f:
dxvk_conf_path.parent.mkdir(parents=True, exist_ok=True)
with open(dxvk_conf_path, 'w', encoding='utf-8') as f:
f.write(dxvk_conf_content)
logger.info(f"dxvk.conf created successfully at {dxvk_conf_path}")
@@ -362,6 +361,99 @@ class PathHandler:
except Exception as e:
logger.error(f"Error creating dxvk.conf: {e}")
return False
@staticmethod
def verify_dxvk_conf_exists(modlist_dir, steam_library, game_var_full, vanilla_game_dir=None, stock_game_path=None) -> bool:
"""
Verify that dxvk.conf exists in at least one of the candidate directories and contains the required setting.
"""
required_line = "dxvk.enableGraphicsPipelineLibrary = False"
candidate_dirs = PathHandler._build_dxvk_candidate_dirs(
modlist_dir=modlist_dir,
stock_game_path=stock_game_path,
steam_library=steam_library,
game_var_full=game_var_full,
vanilla_game_dir=vanilla_game_dir
)
for directory in candidate_dirs:
conf_path = directory / "dxvk.conf"
if conf_path.is_file():
try:
with open(conf_path, 'r', encoding='utf-8') as f:
content = f.read()
if required_line not in content:
logger.warning(f"dxvk.conf found at {conf_path} but missing required setting. Appending now.")
with open(conf_path, 'a', encoding='utf-8') as f:
if not content.endswith('\n'):
f.write('\n')
f.write(required_line + '\n')
logger.info(f"Verified dxvk.conf at {conf_path}")
return True
except Exception as e:
logger.warning(f"Failed to verify dxvk.conf at {conf_path}: {e}")
logger.warning("dxvk.conf verification failed - file not found in any candidate directory.")
return False
@staticmethod
def _normalize_common_library_path(steam_library: Optional[str]) -> Optional[Path]:
if not steam_library:
return None
path = Path(steam_library)
parts_lower = [part.lower() for part in path.parts]
if len(parts_lower) >= 2 and parts_lower[-2:] == ['steamapps', 'common']:
return path
if parts_lower and parts_lower[-1] == 'common':
return path
if 'steamapps' in parts_lower:
idx = parts_lower.index('steamapps')
truncated = Path(*path.parts[:idx + 1])
return truncated / 'common'
return path / 'steamapps' / 'common'
@staticmethod
def _build_dxvk_candidate_dirs(modlist_dir, stock_game_path, steam_library, game_var_full, vanilla_game_dir) -> List[Path]:
candidates: List[Path] = []
seen = set()
def add_candidate(path_obj: Optional[Path]):
if not path_obj:
return
key = path_obj.resolve() if path_obj.exists() else path_obj
if key in seen:
return
seen.add(key)
candidates.append(path_obj)
if stock_game_path:
add_candidate(Path(stock_game_path))
if modlist_dir:
base_path = Path(modlist_dir)
common_names = [
"Stock Game",
"Game Root",
"STOCK GAME",
"Stock Game Folder",
"Stock Folder",
"Skyrim Stock",
os.path.join("root", "Skyrim Special Edition")
]
for name in common_names:
add_candidate(base_path / name)
steam_common = PathHandler._normalize_common_library_path(steam_library)
if steam_common and game_var_full:
add_candidate(steam_common / game_var_full)
if vanilla_game_dir:
add_candidate(Path(vanilla_game_dir))
if modlist_dir:
add_candidate(Path(modlist_dir))
return candidates
@staticmethod
def find_steam_config_vdf() -> Optional[Path]:
@@ -491,40 +583,53 @@ class PathHandler:
logger.debug(f"Searching for compatdata directory for AppID: {appid}")
# Use libraryfolders.vdf to find all Steam library paths
# Use libraryfolders.vdf to find all Steam library paths, when available
library_paths = PathHandler.get_all_steam_library_paths()
if not library_paths:
logger.error("Could not find any Steam library paths from libraryfolders.vdf")
return None
if library_paths:
logger.debug(f"Checking compatdata in {len(library_paths)} Steam libraries")
# Check each Steam library's compatdata directory
for library_path in library_paths:
compatdata_base = library_path / "steamapps" / "compatdata"
if not compatdata_base.is_dir():
logger.debug(f"Compatdata directory does not exist: {compatdata_base}")
continue
potential_path = compatdata_base / appid
if potential_path.is_dir():
logger.info(f"Found compatdata directory: {potential_path}")
return potential_path
else:
logger.debug(f"Compatdata for AppID {appid} not found in {compatdata_base}")
logger.debug(f"Checking compatdata in {len(library_paths)} Steam libraries")
# Check fallback locations only if we didn't find valid libraries
# If we have valid libraries from libraryfolders.vdf, we should NOT fall back to wrong locations
is_flatpak_steam = any('.var/app/com.valvesoftware.Steam' in str(lib) for lib in library_paths) if library_paths else False
# Check each Steam library's compatdata directory
for library_path in library_paths:
compatdata_base = library_path / "steamapps" / "compatdata"
if not compatdata_base.is_dir():
logger.debug(f"Compatdata directory does not exist: {compatdata_base}")
continue
potential_path = compatdata_base / appid
if potential_path.is_dir():
logger.info(f"Found compatdata directory: {potential_path}")
return potential_path
if not library_paths or is_flatpak_steam:
# Only check Flatpak-specific fallbacks if we have Flatpak Steam
logger.debug("Checking fallback compatdata locations...")
if is_flatpak_steam:
# For Flatpak Steam, only check Flatpak-specific locations
fallback_locations = [
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/compatdata",
Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam/steamapps/compatdata",
]
else:
logger.debug(f"Compatdata for AppID {appid} not found in {compatdata_base}")
# Fallback: Broad search (can be slow, consider if needed)
# try:
# logger.debug(f"Compatdata not found in standard locations, attempting wider search...")
# # This can be very slow and resource-intensive
# # find_output = subprocess.check_output(['find', '/', '-type', 'd', '-name', appid, '-path', '*/compatdata/*', '-print', '-quit', '2>/dev/null'], text=True).strip()
# # if find_output:
# # logger.info(f"Found compatdata via find command: {find_output}")
# # return Path(find_output)
# except Exception as e:
# logger.warning(f"Error during 'find' command for compatdata: {e}")
logger.warning(f"Compatdata directory for AppID {appid} not found.")
# For native Steam or unknown, check standard locations
fallback_locations = [
Path.home() / ".local/share/Steam/steamapps/compatdata",
Path.home() / ".steam/steam/steamapps/compatdata",
]
for compatdata_base in fallback_locations:
if compatdata_base.is_dir():
potential_path = compatdata_base / appid
if potential_path.is_dir():
logger.warning(f"Found compatdata directory in fallback location (may be from old incorrect creation): {potential_path}")
return potential_path
logger.warning(f"Compatdata directory for AppID {appid} not found in any Steam library or fallback location.")
return None
@staticmethod
@@ -617,14 +722,22 @@ class PathHandler:
if vdf_path.is_file():
logger.info(f"[DEBUG] Parsing libraryfolders.vdf: {vdf_path}")
try:
with open(vdf_path) as f:
for line in f:
m = re.search(r'"path"\s*"([^"]+)"', line)
if m:
lib_path = Path(m.group(1))
with open(vdf_path, 'r', encoding='utf-8') as f:
data = vdf.load(f)
# libraryfolders.vdf structure: libraryfolders -> "0", "1", etc. -> "path"
libraryfolders = data.get('libraryfolders', {})
for key, lib_data in libraryfolders.items():
if isinstance(lib_data, dict) and 'path' in lib_data:
lib_path = Path(lib_data['path'])
# Resolve symlinks for consistency (mmcblk0p1 -> deck/UUID)
resolved_path = lib_path.resolve()
library_paths.add(resolved_path)
try:
resolved_path = lib_path.resolve()
library_paths.add(resolved_path)
logger.debug(f"[DEBUG] Found library path: {resolved_path}")
except (OSError, RuntimeError) as resolve_err:
# If resolve fails, use original path
logger.warning(f"[DEBUG] Could not resolve {lib_path}, using as-is: {resolve_err}")
library_paths.add(lib_path)
except Exception as e:
logger.error(f"[DEBUG] Failed to parse {vdf_path}: {e}")
logger.info(f"[DEBUG] All detected Steam libraries: {library_paths}")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
"""
Example usage of ProgressParser
This file demonstrates how to use the progress parser to extract
structured information from jackify-engine output.
R&D NOTE: This is experimental code for investigation purposes.
"""
from jackify.backend.handlers.progress_parser import ProgressStateManager
def example_usage():
"""Example of how to use the progress parser."""
# Create state manager
state_manager = ProgressStateManager()
# Simulate processing lines from jackify-engine output
sample_lines = [
"[00:00:00] === Installing files ===",
"[00:00:05] [12/14] Installing files (1.1GB/56.3GB)",
"[00:00:10] Installing: Enderal Remastered Armory.7z (42%)",
"[00:00:15] Extracting: Mandragora Sprouts.7z (96%)",
"[00:00:20] Downloading at 45.2MB/s",
"[00:00:25] Extracting at 267.3MB/s",
"[00:00:30] Progress: 85%",
]
print("Processing sample output lines...\n")
for line in sample_lines:
updated = state_manager.process_line(line)
if updated:
state = state_manager.get_state()
print(f"Line: {line}")
print(f" Phase: {state.phase.value} - {state.phase_name}")
print(f" Progress: {state.overall_percent:.1f}%")
print(f" Step: {state.phase_progress_text}")
print(f" Data: {state.data_progress_text}")
print(f" Active Files: {len(state.active_files)}")
for file_prog in state.active_files:
print(f" - {file_prog.filename}: {file_prog.percent:.1f}%")
print(f" Speeds: {state.speeds}")
print(f" Display: {state.display_text}")
print()
if __name__ == "__main__":
example_usage()

View File

@@ -34,36 +34,134 @@ class ProtontricksHandler:
self.steamdeck = steamdeck # Store steamdeck status
self._native_steam_service = None
self.use_native_operations = True # Enable native Steam operations by default
def _get_steam_dir_from_libraryfolders(self) -> Optional[Path]:
"""
Determine the Steam installation directory from libraryfolders.vdf location.
This is the source of truth - we read libraryfolders.vdf to find where Steam is actually installed.
Returns:
Path to Steam installation directory (the one with config/, steamapps/, etc.) or None
"""
from ..handlers.path_handler import PathHandler
# Check all possible libraryfolders.vdf locations
vdf_paths = [
Path.home() / ".steam/steam/config/libraryfolders.vdf",
Path.home() / ".local/share/Steam/config/libraryfolders.vdf",
Path.home() / ".steam/root/config/libraryfolders.vdf",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf", # Flatpak
Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam/config/libraryfolders.vdf", # Flatpak alternative
]
for vdf_path in vdf_paths:
if vdf_path.is_file():
# The Steam installation directory is the parent of the config directory
steam_dir = vdf_path.parent.parent
# Verify it has steamapps directory (required by protontricks)
if (steam_dir / "steamapps").exists():
logger.debug(f"Determined STEAM_DIR from libraryfolders.vdf: {steam_dir}")
return steam_dir
# Fallback: try to get from library paths
library_paths = PathHandler.get_all_steam_library_paths()
if library_paths:
# For Flatpak Steam, library path is .local/share/Steam, but Steam installation might be data/Steam
first_lib = library_paths[0]
if '.var/app/com.valvesoftware.Steam' in str(first_lib):
# Check if data/Steam exists (main Flatpak Steam installation)
data_steam = Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam"
if (data_steam / "steamapps").exists():
logger.debug(f"Determined STEAM_DIR from Flatpak data path: {data_steam}")
return data_steam
# Otherwise use the library path itself
if (first_lib / "steamapps").exists():
logger.debug(f"Determined STEAM_DIR from Flatpak library path: {first_lib}")
return first_lib
else:
# Native Steam - library path should be the Steam installation
if (first_lib / "steamapps").exists():
logger.debug(f"Determined STEAM_DIR from native library path: {first_lib}")
return first_lib
logger.warning("Could not determine STEAM_DIR from libraryfolders.vdf")
return None
def _get_bundled_winetricks_path(self) -> Optional[Path]:
"""
Get the path to the bundled winetricks script following AppImage best practices.
Same logic as WinetricksHandler._get_bundled_winetricks_path()
"""
possible_paths = []
# AppImage environment - use APPDIR (standard AppImage best practice)
if os.environ.get('APPDIR'):
appdir_path = Path(os.environ['APPDIR']) / 'opt' / 'jackify' / 'tools' / 'winetricks'
possible_paths.append(appdir_path)
# Development environment - relative to module location
module_dir = Path(__file__).parent.parent.parent # Go from handlers/ up to jackify/
dev_path = module_dir / 'tools' / 'winetricks'
possible_paths.append(dev_path)
# Try each path until we find one that works
for path in possible_paths:
if path.exists() and os.access(path, os.X_OK):
logger.debug(f"Found bundled winetricks at: {path}")
return path
logger.warning(f"Bundled winetricks not found. Tried paths: {possible_paths}")
return None
def _get_bundled_cabextract_path(self) -> Optional[Path]:
"""
Get the path to the bundled cabextract binary following AppImage best practices.
Same logic as WinetricksHandler._get_bundled_cabextract()
"""
possible_paths = []
# AppImage environment - use APPDIR (standard AppImage best practice)
if os.environ.get('APPDIR'):
appdir_path = Path(os.environ['APPDIR']) / 'opt' / 'jackify' / 'tools' / 'cabextract'
possible_paths.append(appdir_path)
# Development environment - relative to module location
module_dir = Path(__file__).parent.parent.parent # Go from handlers/ up to jackify/
dev_path = module_dir / 'tools' / 'cabextract'
possible_paths.append(dev_path)
# Try each path until we find one that works
for path in possible_paths:
if path.exists() and os.access(path, os.X_OK):
logger.debug(f"Found bundled cabextract at: {path}")
return path
logger.warning(f"Bundled cabextract not found. Tried paths: {possible_paths}")
return None
def _get_clean_subprocess_env(self):
"""
Create a clean environment for subprocess calls by removing PyInstaller-specific
Create a clean environment for subprocess calls by removing bundle-specific
environment variables that can interfere with external program execution.
Uses the centralized get_clean_subprocess_env() to ensure AppImage variables
are removed to prevent subprocess spawning issues.
Returns:
dict: Cleaned environment dictionary
"""
env = os.environ.copy()
# Remove PyInstaller-specific environment variables
env.pop('_MEIPASS', None)
env.pop('_MEIPASS2', None)
# Clean library path variables that PyInstaller modifies (Linux/Unix)
# Use centralized function that removes AppImage variables
from .subprocess_utils import get_clean_subprocess_env
env = get_clean_subprocess_env()
# Clean library path variables that frozen bundles modify (Linux/Unix)
if 'LD_LIBRARY_PATH_ORIG' in env:
# Restore original LD_LIBRARY_PATH if it was backed up by PyInstaller
# Restore original LD_LIBRARY_PATH if it was backed up by the bundler
env['LD_LIBRARY_PATH'] = env['LD_LIBRARY_PATH_ORIG']
else:
# Remove PyInstaller-modified LD_LIBRARY_PATH
# Remove bundle-modified LD_LIBRARY_PATH
env.pop('LD_LIBRARY_PATH', None)
# Clean PATH of PyInstaller-specific entries
if 'PATH' in env and hasattr(sys, '_MEIPASS'):
path_entries = env['PATH'].split(os.pathsep)
# 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)
# 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)
@@ -84,17 +182,17 @@ class ProtontricksHandler:
def detect_protontricks(self):
"""
Detect if protontricks is installed and whether it's flatpak or native.
If not found, prompts the user to install the Flatpak version.
Detect if protontricks is installed (silent detection for GUI/automated use).
Returns True if protontricks is found or successfully installed, False otherwise
Returns True if protontricks is found, False otherwise.
Does NOT prompt user or attempt installation - that's handled by the GUI.
"""
logger.debug("Detecting if protontricks is installed...")
# Check if protontricks exists as a command
protontricks_path_which = shutil.which("protontricks")
self.flatpak_path = shutil.which("flatpak") # Store for later use
self.flatpak_path = shutil.which("flatpak")
if protontricks_path_which:
# Check if it's a flatpak wrapper
try:
@@ -103,7 +201,6 @@ class ProtontricksHandler:
if "flatpak run" in content:
logger.debug(f"Detected Protontricks is a Flatpak wrapper at {protontricks_path_which}")
self.which_protontricks = 'flatpak'
# Continue to check flatpak list just to be sure
else:
logger.info(f"Native Protontricks found at {protontricks_path_which}")
self.which_protontricks = 'native'
@@ -111,103 +208,27 @@ class ProtontricksHandler:
return True
except Exception as e:
logger.error(f"Error reading protontricks executable: {e}")
# Check if flatpak protontricks is installed (or if wrapper check indicated flatpak)
flatpak_installed = False
try:
# PyInstaller fix: Comprehensive environment cleaning for subprocess calls
env = self._get_clean_subprocess_env()
# Check if flatpak protontricks is installed
try:
env = self._get_clean_subprocess_env()
result = subprocess.run(
["flatpak", "list"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, # Suppress stderr to avoid error messages when flatpak not installed
capture_output=True,
text=True,
env=env # Use comprehensively cleaned environment
env=env
)
if result.returncode == 0 and "com.github.Matoking.protontricks" in result.stdout:
logger.info("Flatpak Protontricks is installed")
self.which_protontricks = 'flatpak'
flatpak_installed = True
return True
except FileNotFoundError:
logger.warning("'flatpak' command not found. Cannot check for Flatpak Protontricks.")
logger.warning("'flatpak' command not found. Cannot check for Flatpak Protontricks.")
except Exception as e:
logger.error(f"Unexpected error checking flatpak: {e}")
# If neither native nor flatpak found, prompt for installation
if not self.which_protontricks:
logger.warning("Protontricks not found (native or flatpak).")
should_install = False
if self.steamdeck:
logger.info("Running on Steam Deck, attempting automatic Flatpak installation.")
# Maybe add a brief pause or message?
print("Protontricks not found. Attempting automatic installation via Flatpak...")
should_install = True
else:
try:
print("\nProtontricks not found. Choose installation method:")
print("1. Install via Flatpak (automatic)")
print("2. Install via native package manager (manual)")
print("3. Skip (Use bundled winetricks instead)")
choice = input("Enter choice (1/2/3): ").strip()
if choice == '1' or choice == '':
should_install = True
elif choice == '2':
print("\nTo install protontricks via your system package manager:")
print("• Ubuntu/Debian: sudo apt install protontricks")
print("• Fedora: sudo dnf install protontricks")
print("• Arch Linux: sudo pacman -S protontricks")
print("• openSUSE: sudo zypper install protontricks")
print("\nAfter installation, please rerun Jackify.")
return False
elif choice == '3':
print("Skipping protontricks installation. Will use bundled winetricks for component installation.")
logger.info("User chose to skip protontricks and use winetricks fallback")
return False
else:
print("Invalid choice. Installation cancelled.")
return False
except KeyboardInterrupt:
print("\nInstallation cancelled.")
return False
if should_install:
try:
logger.info("Attempting to install Flatpak Protontricks...")
# Use --noninteractive for automatic install where applicable
install_cmd = ["flatpak", "install", "-u", "-y", "--noninteractive", "flathub", "com.github.Matoking.protontricks"]
# PyInstaller fix: Comprehensive environment cleaning for subprocess calls
env = self._get_clean_subprocess_env()
# Run with output visible to user
process = subprocess.run(install_cmd, check=True, text=True, env=env)
logger.info("Flatpak Protontricks installation successful.")
print("Flatpak Protontricks installed successfully.")
self.which_protontricks = 'flatpak'
return True
except FileNotFoundError:
logger.error("'flatpak' command not found. Cannot install.")
print("Error: 'flatpak' command not found. Please install Flatpak first.")
return False
except subprocess.CalledProcessError as e:
logger.error(f"Flatpak installation failed: {e}")
print(f"Error: Flatpak installation failed (Command: {' '.join(e.cmd)}). Please try installing manually.")
return False
except Exception as e:
logger.error(f"Unexpected error during Flatpak installation: {e}")
print("An unexpected error occurred during installation.")
return False
else:
logger.error("User chose not to install Protontricks or installation skipped.")
print("Protontricks installation skipped. Cannot continue without Protontricks.")
return False
# Should not reach here if logic is correct, but acts as a fallback
logger.error("Protontricks detection failed unexpectedly.")
# Not found
logger.warning("Protontricks not found (native or flatpak).")
return False
def check_protontricks_version(self):
@@ -257,13 +278,30 @@ class ProtontricksHandler:
logger.error("Could not detect protontricks installation")
return None
if self.which_protontricks == 'flatpak':
# Build command based on detected protontricks type
if self.which_protontricks == 'bundled':
# CRITICAL: Use safe Python executable to prevent AppImage recursive spawning
from .subprocess_utils import get_safe_python_executable
python_exe = get_safe_python_executable()
# Use bundled wrapper script for reliable invocation
# The wrapper script imports cli and calls it with sys.argv
wrapper_script = self._get_bundled_protontricks_wrapper_path()
if wrapper_script and Path(wrapper_script).exists():
cmd = [python_exe, str(wrapper_script)]
cmd.extend([str(a) for a in args])
else:
# Fallback: use python -m to run protontricks CLI directly
# This avoids importing protontricks.__init__ which imports gui.py which needs Pillow
cmd = [python_exe, "-m", "protontricks.cli.main"]
cmd.extend([str(a) for a in args])
elif self.which_protontricks == 'flatpak':
cmd = ["flatpak", "run", "com.github.Matoking.protontricks"]
else:
cmd.extend(args)
else: # native
cmd = ["protontricks"]
cmd.extend(args)
cmd.extend(args)
# Default to capturing stdout/stderr unless specified otherwise in kwargs
run_kwargs = {
'stdout': subprocess.PIPE,
@@ -271,18 +309,73 @@ class ProtontricksHandler:
'text': True,
**kwargs # Allow overriding defaults (like stderr=DEVNULL)
}
# PyInstaller fix: Use cleaned environment for all protontricks calls
env = self._get_clean_subprocess_env()
# Handle environment: if env was passed in kwargs, merge it with our clean env
# Otherwise create a clean env from scratch
if 'env' in kwargs and kwargs['env']:
# Merge passed env with our clean env (our values take precedence)
env = self._get_clean_subprocess_env()
env.update(kwargs['env']) # Merge passed env, but our clean env is base
# Re-apply our critical settings after merge to ensure they're set
else:
# Bundled-runtime fix: Use cleaned environment for all protontricks calls
env = self._get_clean_subprocess_env()
# Suppress Wine debug output
env['WINEDEBUG'] = '-all'
# CRITICAL: Set STEAM_DIR based on libraryfolders.vdf to prevent user prompts
steam_dir = self._get_steam_dir_from_libraryfolders()
if steam_dir:
env['STEAM_DIR'] = str(steam_dir)
logger.debug(f"Set STEAM_DIR for protontricks: {steam_dir}")
else:
logger.warning("Could not determine STEAM_DIR from libraryfolders.vdf - protontricks may prompt user")
# CRITICAL: Only set bundled winetricks for NATIVE protontricks
# Flatpak protontricks runs in a sandbox and CANNOT access AppImage FUSE mounts (/tmp/.mount_*)
# Flatpak protontricks has its own winetricks bundled inside the flatpak
if self.which_protontricks == 'native':
winetricks_path = self._get_bundled_winetricks_path()
if winetricks_path:
env['WINETRICKS'] = str(winetricks_path)
logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}")
else:
logger.warning("Bundled winetricks not found - native protontricks will use system winetricks")
cabextract_path = self._get_bundled_cabextract_path()
if cabextract_path:
cabextract_dir = str(cabextract_path.parent)
current_path = env.get('PATH', '')
env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir
logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}")
else:
logger.warning("Bundled cabextract not found - native protontricks will use system cabextract")
else:
# Flatpak protontricks - DO NOT set bundled paths
logger.debug(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)")
# CRITICAL: Suppress winetricks verbose output when not in debug mode
# WINETRICKS_SUPER_QUIET suppresses "Executing..." messages from winetricks
from ..handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False)
if not debug_mode:
env['WINETRICKS_SUPER_QUIET'] = '1'
logger.debug("Set WINETRICKS_SUPER_QUIET=1 to suppress winetricks verbose output")
else:
logger.debug("Debug mode enabled - winetricks verbose output will be shown")
# Note: No need to modify LD_LIBRARY_PATH for Wine/Proton as it's a system dependency
# Wine/Proton finds its own libraries through the system's library search paths
run_kwargs['env'] = env
try:
return subprocess.run(cmd, **run_kwargs)
except Exception as e:
logger.error(f"Error running protontricks: {e}")
# Consider returning a mock CompletedProcess with an error code?
return None
def set_protontricks_permissions(self, modlist_dir, steamdeck=False):
"""
Set permissions for Steam operations to access the modlist directory.
@@ -304,7 +397,7 @@ class ProtontricksHandler:
logger.info("Setting Protontricks permissions...")
try:
# PyInstaller fix: Use cleaned environment
# Bundled-runtime fix: Use cleaned environment
env = self._get_clean_subprocess_env()
subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks",
@@ -414,7 +507,7 @@ class ProtontricksHandler:
logger.error("Protontricks path not determined, cannot list shortcuts.")
return {}
self.logger.debug(f"Running command: {' '.join(cmd)}")
# PyInstaller fix: Use cleaned environment
# Bundled-runtime fix: Use cleaned environment
env = self._get_clean_subprocess_env()
result = subprocess.run(cmd, capture_output=True, text=True, check=True, encoding='utf-8', errors='ignore', env=env)
# Regex to capture name and AppID
@@ -566,7 +659,7 @@ class ProtontricksHandler:
Returns True on success, False on failure
"""
try:
# PyInstaller fix: Use cleaned environment
# Bundled-runtime fix: Use cleaned environment
env = self._get_clean_subprocess_env()
env["WINEDEBUG"] = "-all"
@@ -652,16 +745,22 @@ class ProtontricksHandler:
def run_protontricks_launch(self, appid, installer_path, *extra_args):
"""
Run protontricks-launch (for WebView or similar installers) using the correct method for flatpak or native.
Run protontricks-launch (for WebView or similar installers) using the correct method for bundled, flatpak, or native.
Returns subprocess.CompletedProcess object.
"""
if self.which_protontricks is None:
if not self.detect_protontricks():
self.logger.error("Could not detect protontricks installation")
return None
if self.which_protontricks == 'flatpak':
if self.which_protontricks == 'bundled':
# CRITICAL: Use safe Python executable to prevent AppImage recursive spawning
from .subprocess_utils import get_safe_python_executable
python_exe = get_safe_python_executable()
# Use bundled Python module
cmd = [python_exe, "-m", "protontricks.cli.launch", "--appid", appid, str(installer_path)]
elif self.which_protontricks == 'flatpak':
cmd = ["flatpak", "run", "--command=protontricks-launch", "com.github.Matoking.protontricks", "--appid", appid, str(installer_path)]
else:
else: # native
launch_path = shutil.which("protontricks-launch")
if not launch_path:
self.logger.error("protontricks-launch command not found in PATH.")
@@ -671,7 +770,7 @@ class ProtontricksHandler:
cmd.extend(extra_args)
self.logger.debug(f"Running protontricks-launch: {' '.join(map(str, cmd))}")
try:
# PyInstaller fix: Use cleaned environment
# Bundled-runtime fix: Use cleaned environment
env = self._get_clean_subprocess_env()
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env)
except Exception as e:
@@ -685,6 +784,44 @@ class ProtontricksHandler:
"""
env = self._get_clean_subprocess_env()
env["WINEDEBUG"] = "-all"
# CRITICAL: Only set bundled winetricks for NATIVE protontricks
# Flatpak protontricks runs in a sandbox and CANNOT access AppImage FUSE mounts (/tmp/.mount_*)
# Flatpak protontricks has its own winetricks bundled inside the flatpak
if self.which_protontricks == 'native':
winetricks_path = self._get_bundled_winetricks_path()
if winetricks_path:
env['WINETRICKS'] = str(winetricks_path)
self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}")
else:
self.logger.warning("Bundled winetricks not found - native protontricks will use system winetricks")
cabextract_path = self._get_bundled_cabextract_path()
if cabextract_path:
cabextract_dir = str(cabextract_path.parent)
current_path = env.get('PATH', '')
env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir
self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}")
else:
self.logger.warning("Bundled cabextract not found - native protontricks will use system cabextract")
else:
# Flatpak protontricks - DO NOT set bundled paths
self.logger.info(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)")
# CRITICAL: Suppress winetricks verbose output when not in debug mode
from ..handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False)
if not debug_mode:
env['WINETRICKS_SUPER_QUIET'] = '1'
self.logger.debug("Set WINETRICKS_SUPER_QUIET=1 in install_wine_components to suppress winetricks verbose output")
# Set up winetricks cache (shared with winetricks_handler for efficiency)
from jackify.shared.paths import get_jackify_data_dir
jackify_cache_dir = get_jackify_data_dir() / 'winetricks_cache'
jackify_cache_dir.mkdir(parents=True, exist_ok=True)
env['WINETRICKS_CACHE'] = str(jackify_cache_dir)
self.logger.info(f"Using winetricks cache: {jackify_cache_dir}")
if specific_components is not None:
components_to_install = specific_components
self.logger.info(f"Installing specific components: {components_to_install}")
@@ -716,8 +853,25 @@ class ProtontricksHandler:
# Continue to retry
else:
self.logger.error(f"Protontricks command failed (Attempt {attempt}/{max_attempts}). Return Code: {result.returncode if result else 'N/A'}")
self.logger.error(f"Stdout: {result.stdout.strip() if result else ''}")
self.logger.error(f"Stderr: {result.stderr.strip() if result else ''}")
# Only show stdout/stderr in debug mode to avoid verbose output
from ..handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False)
if debug_mode:
self.logger.error(f"Stdout: {result.stdout.strip() if result else ''}")
self.logger.error(f"Stderr: {result.stderr.strip() if result else ''}")
else:
# In non-debug mode, only show stderr if it contains actual errors (not verbose winetricks output)
if result and result.stderr:
stderr_lower = result.stderr.lower()
# Filter out verbose winetricks messages
if any(keyword in stderr_lower for keyword in ['error', 'failed', 'cannot', 'warning: cannot find']):
# Only show actual errors, not "Executing..." messages
error_lines = [line for line in result.stderr.strip().split('\n')
if any(keyword in line.lower() for keyword in ['error', 'failed', 'cannot', 'warning: cannot find'])
and 'executing' not in line.lower()]
if error_lines:
self.logger.error(f"Stderr (errors only): {' '.join(error_lines)}")
except Exception as e:
self.logger.error(f"Error during protontricks run (Attempt {attempt}/{max_attempts}): {e}", exc_info=True)
self.logger.error(f"Failed to install Wine components after {max_attempts} attempts.")

View File

@@ -198,7 +198,10 @@ class ShortcutHandler:
if steam_vdf_spec is None:
# Try to install steam-vdf using pip
print("Installing required dependency (steam-vdf)...")
subprocess.check_call([sys.executable, "-m", "pip", "install", "steam-vdf", "--user"])
# CRITICAL: Use safe Python executable to prevent AppImage recursive spawning
from jackify.backend.handlers.subprocess_utils import get_safe_python_executable
python_exe = get_safe_python_executable()
subprocess.check_call([python_exe, "-m", "pip", "install", "steam-vdf", "--user"])
time.sleep(1) # Give some time for the install to complete
# Now import it

View File

@@ -3,17 +3,119 @@ import signal
import subprocess
import time
import resource
import sys
import shutil
def get_safe_python_executable():
"""
Get a safe Python executable for subprocess calls.
When running as AppImage, returns system Python instead of AppImage path
to prevent recursive AppImage spawning.
Returns:
str: Path to Python executable safe for subprocess calls
"""
# Check if we're running as AppImage
is_appimage = (
'APPIMAGE' in os.environ or
'APPDIR' in os.environ or
(hasattr(sys, 'frozen') and sys.frozen) or
(sys.argv[0] and sys.argv[0].endswith('.AppImage'))
)
if is_appimage:
# Running as AppImage - use system Python to avoid recursive spawning
# Try to find system Python (same logic as AppRun)
for cmd in ['python3', 'python3.13', 'python3.12', 'python3.11', 'python3.10', 'python3.9', 'python3.8']:
python_path = shutil.which(cmd)
if python_path:
return python_path
# Fallback: if we can't find system Python, this is a problem
# But we'll still return sys.executable as last resort
return sys.executable
else:
# Not AppImage - sys.executable is safe
return sys.executable
def get_clean_subprocess_env(extra_env=None):
"""
Returns a copy of os.environ with PyInstaller and other problematic variables removed.
Returns a copy of os.environ with bundled-runtime variables and other problematic entries removed.
Optionally merges in extra_env dict.
Also ensures bundled tools (lz4, unzip, etc.) are in PATH when running as AppImage.
CRITICAL: Preserves system PATH to ensure system tools (like lz4) are available.
"""
from pathlib import Path
env = os.environ.copy()
# Remove PyInstaller-specific variables
# Remove AppImage-specific variables that can confuse subprocess calls
# These variables cause subprocesses to be interpreted as new AppImage launches
for key in ['APPIMAGE', 'APPDIR', 'ARGV0', 'OWD']:
env.pop(key, None)
# Remove bundle-specific variables
for k in list(env):
if k.startswith('_MEIPASS'):
del env[k]
# Get current PATH - ensure we preserve system paths
current_path = env.get('PATH', '')
# Ensure common system directories are in PATH if not already present
# This is critical for tools like lz4 that might be in /usr/bin, /usr/local/bin, etc.
system_paths = ['/usr/bin', '/usr/local/bin', '/bin', '/sbin', '/usr/sbin']
path_parts = current_path.split(':') if current_path else []
for sys_path in system_paths:
if sys_path not in path_parts and os.path.isdir(sys_path):
path_parts.append(sys_path)
# Add bundled tools directory to PATH if running as AppImage
# This ensures lz4, unzip, xz, etc. are available to subprocesses
appdir = env.get('APPDIR')
tools_dir = None
if appdir:
# Running as AppImage - use APPDIR
tools_dir = os.path.join(appdir, 'opt', 'jackify', 'tools')
# Verify the tools directory exists and contains lz4
if not os.path.isdir(tools_dir):
tools_dir = None
elif not os.path.exists(os.path.join(tools_dir, 'lz4')):
# Tools dir exists but lz4 not there - might be a different layout
tools_dir = None
elif getattr(sys, 'frozen', False):
# PyInstaller frozen - try to find tools relative to executable
exe_path = Path(sys.executable)
# In PyInstaller, sys.executable is the bundled executable
# Tools should be in the same directory or a tools subdirectory
possible_tools_dirs = [
exe_path.parent / 'tools',
exe_path.parent / 'opt' / 'jackify' / 'tools',
]
for possible_dir in possible_tools_dirs:
if possible_dir.is_dir() and (possible_dir / 'lz4').exists():
tools_dir = str(possible_dir)
break
# Build final PATH: bundled tools first (if any), then original PATH with system paths
final_path_parts = []
if tools_dir and os.path.isdir(tools_dir):
# Prepend tools directory so bundled tools take precedence
# This is critical - bundled lz4 must come before system lz4
final_path_parts.append(tools_dir)
# Add all other paths (preserving order, removing duplicates)
# Note: AppRun already sets PATH with tools directory, but we ensure it's first
seen = set()
if tools_dir:
seen.add(tools_dir) # Already added, don't add again
for path_part in path_parts:
if path_part and path_part not in seen:
final_path_parts.append(path_part)
seen.add(path_part)
env['PATH'] = ':'.join(final_path_parts)
# Optionally restore LD_LIBRARY_PATH to system default if needed
# (You can add more logic here if you know your system's default)
if extra_env:
@@ -59,7 +161,11 @@ class ProcessManager:
"""
def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0):
self.cmd = cmd
self.env = env
# Default to cleaned environment if None to prevent AppImage variable inheritance
if env is None:
self.env = get_clean_subprocess_env()
else:
self.env = env
self.cwd = cwd
self.text = text
self.bufsize = bufsize

View File

@@ -0,0 +1,742 @@
"""
TTW_Linux_Installer Handler
Handles downloading, installation, and execution of TTW_Linux_Installer for TTW installations.
Replaces hoolamike for TTW-specific functionality.
"""
import logging
import os
import subprocess
import tarfile
import zipfile
from pathlib import Path
from typing import Optional, Tuple
import requests
from .path_handler import PathHandler
from .filesystem_handler import FileSystemHandler
from .config_handler import ConfigHandler
from .logging_handler import LoggingHandler
from .subprocess_utils import get_clean_subprocess_env
logger = logging.getLogger(__name__)
# Define default TTW_Linux_Installer paths
JACKIFY_BASE_DIR = Path.home() / "Jackify"
DEFAULT_TTW_INSTALLER_DIR = JACKIFY_BASE_DIR / "TTW_Linux_Installer"
TTW_INSTALLER_EXECUTABLE_NAME = "ttw_linux_gui" # Same executable, runs in CLI mode with args
# GitHub release info
TTW_INSTALLER_REPO = "SulfurNitride/TTW_Linux_Installer"
TTW_INSTALLER_RELEASE_URL = f"https://api.github.com/repos/{TTW_INSTALLER_REPO}/releases/latest"
class TTWInstallerHandler:
"""Handles TTW installation using TTW_Linux_Installer (replaces hoolamike for TTW)."""
def __init__(self, steamdeck: bool, verbose: bool, filesystem_handler: FileSystemHandler,
config_handler: ConfigHandler, menu_handler=None):
"""Initialize the handler."""
self.steamdeck = steamdeck
self.verbose = verbose
self.path_handler = PathHandler()
self.filesystem_handler = filesystem_handler
self.config_handler = config_handler
self.menu_handler = menu_handler
# Set up logging
logging_handler = LoggingHandler()
logging_handler.rotate_log_for_logger('ttw-install', 'TTW_Install_workflow.log')
self.logger = logging_handler.setup_logger('ttw-install', 'TTW_Install_workflow.log')
# Installation paths
self.ttw_installer_dir: Path = DEFAULT_TTW_INSTALLER_DIR
self.ttw_installer_executable_path: Optional[Path] = None
self.ttw_installer_installed: bool = False
# Load saved install path from config
saved_path_str = self.config_handler.get('ttw_installer_install_path')
if saved_path_str and Path(saved_path_str).is_dir():
self.ttw_installer_dir = Path(saved_path_str)
self.logger.info(f"Loaded TTW_Linux_Installer path from config: {self.ttw_installer_dir}")
# Check if already installed
self._check_installation()
def _ensure_dirs_exist(self):
"""Ensure base directories exist."""
self.ttw_installer_dir.mkdir(parents=True, exist_ok=True)
def _check_installation(self):
"""Check if TTW_Linux_Installer is installed at expected location."""
self._ensure_dirs_exist()
potential_exe_path = self.ttw_installer_dir / TTW_INSTALLER_EXECUTABLE_NAME
if potential_exe_path.is_file() and os.access(potential_exe_path, os.X_OK):
self.ttw_installer_executable_path = potential_exe_path
self.ttw_installer_installed = True
self.logger.info(f"Found TTW_Linux_Installer at: {self.ttw_installer_executable_path}")
else:
self.ttw_installer_installed = False
self.ttw_installer_executable_path = None
self.logger.info(f"TTW_Linux_Installer not found at {potential_exe_path}")
def install_ttw_installer(self, install_dir: Optional[Path] = None) -> Tuple[bool, str]:
"""Download and install TTW_Linux_Installer from GitHub releases.
Args:
install_dir: Optional directory to install to (defaults to ~/Jackify/TTW_Linux_Installer)
Returns:
(success: bool, message: str)
"""
try:
self._ensure_dirs_exist()
target_dir = Path(install_dir) if install_dir else self.ttw_installer_dir
target_dir.mkdir(parents=True, exist_ok=True)
# Fetch latest release info
self.logger.info(f"Fetching latest TTW_Linux_Installer release from {TTW_INSTALLER_RELEASE_URL}")
resp = requests.get(TTW_INSTALLER_RELEASE_URL, timeout=15, verify=True)
resp.raise_for_status()
data = resp.json()
release_tag = data.get("tag_name") or data.get("name")
# Find Linux asset - universal-mpi-installer pattern (can be .zip or .tar.gz)
linux_asset = None
asset_names = [asset.get("name", "") for asset in data.get("assets", [])]
self.logger.info(f"Available release assets: {asset_names}")
for asset in data.get("assets", []):
name = asset.get("name", "").lower()
# Look for universal-mpi-installer pattern
if "universal-mpi-installer" in name and name.endswith((".zip", ".tar.gz")):
linux_asset = asset
self.logger.info(f"Found Linux asset: {asset.get('name')}")
break
if not linux_asset:
# Log all available assets for debugging
all_assets = [asset.get("name", "") for asset in data.get("assets", [])]
self.logger.error(f"No suitable Linux asset found. Available assets: {all_assets}")
return False, f"No suitable Linux TTW_Linux_Installer asset found in latest release. Available assets: {', '.join(all_assets)}"
download_url = linux_asset.get("browser_download_url")
asset_name = linux_asset.get("name")
if not download_url or not asset_name:
return False, "Latest release is missing required asset metadata"
# Download to target directory
temp_path = target_dir / asset_name
self.logger.info(f"Downloading {asset_name} from {download_url}")
if not self.filesystem_handler.download_file(download_url, temp_path, overwrite=True, quiet=True):
return False, "Failed to download TTW_Linux_Installer asset"
# Extract archive (zip or tar.gz)
try:
self.logger.info(f"Extracting {asset_name} to {target_dir}")
if asset_name.lower().endswith('.tar.gz'):
with tarfile.open(temp_path, "r:gz") as tf:
tf.extractall(path=target_dir)
elif asset_name.lower().endswith('.zip'):
with zipfile.ZipFile(temp_path, "r") as zf:
zf.extractall(path=target_dir)
else:
return False, f"Unsupported archive format: {asset_name}"
finally:
try:
temp_path.unlink(missing_ok=True) # cleanup
except Exception:
pass
# Find executable (may be in subdirectory or root)
exe_path = target_dir / TTW_INSTALLER_EXECUTABLE_NAME
if not exe_path.is_file():
# Search for it
for p in target_dir.rglob(TTW_INSTALLER_EXECUTABLE_NAME):
if p.is_file():
exe_path = p
break
if not exe_path.is_file():
return False, "TTW_Linux_Installer executable not found after extraction"
# Set executable permissions
try:
os.chmod(exe_path, 0o755)
except Exception as e:
self.logger.warning(f"Failed to chmod +x on {exe_path}: {e}")
# Update state
self.ttw_installer_dir = target_dir
self.ttw_installer_executable_path = exe_path
self.ttw_installer_installed = True
self.config_handler.set('ttw_installer_install_path', str(target_dir))
if release_tag:
self.config_handler.set('ttw_installer_version', release_tag)
self.logger.info(f"TTW_Linux_Installer installed successfully at {exe_path}")
return True, f"TTW_Linux_Installer installed at {target_dir}"
except Exception as e:
self.logger.error(f"Error installing TTW_Linux_Installer: {e}", exc_info=True)
return False, f"Error installing TTW_Linux_Installer: {e}"
def get_installed_ttw_installer_version(self) -> Optional[str]:
"""Return the installed TTW_Linux_Installer version stored in Jackify config, if any."""
try:
v = self.config_handler.get('ttw_installer_version')
return str(v) if v else None
except Exception:
return None
def is_ttw_installer_update_available(self) -> Tuple[bool, Optional[str], Optional[str]]:
"""
Check GitHub for the latest TTW_Linux_Installer release and compare with installed version.
Returns (update_available, installed_version, latest_version).
"""
installed = self.get_installed_ttw_installer_version()
# If executable exists but no version is recorded, don't show as "out of date"
# This can happen if the executable was installed before version tracking was added
if not installed and self.ttw_installer_installed:
self.logger.info("TTW_Linux_Installer executable found but no version recorded in config")
# Don't treat as update available - just show as "Ready" (unknown version)
return (False, None, None)
try:
resp = requests.get(TTW_INSTALLER_RELEASE_URL, timeout=10, verify=True)
resp.raise_for_status()
latest = resp.json().get('tag_name') or resp.json().get('name')
if not latest:
return (False, installed, None)
if not installed:
# No version recorded and executable doesn't exist; treat as not installed
return (False, None, str(latest))
return (installed != str(latest), installed, str(latest))
except Exception as e:
self.logger.warning(f"Error checking for TTW_Linux_Installer updates: {e}")
return (False, installed, None)
def install_ttw_backend(self, ttw_mpi_path: Path, ttw_output_path: Path) -> Tuple[bool, str]:
"""Install TTW using TTW_Linux_Installer.
Args:
ttw_mpi_path: Path to TTW .mpi file
ttw_output_path: Target installation directory
Returns:
(success: bool, message: str)
"""
self.logger.info("Starting Tale of Two Wastelands installation via TTW_Linux_Installer")
# Validate parameters
if not ttw_mpi_path or not ttw_output_path:
return False, "Missing required parameters: ttw_mpi_path and ttw_output_path are required"
ttw_mpi_path = Path(ttw_mpi_path)
ttw_output_path = Path(ttw_output_path)
# Validate paths
if not ttw_mpi_path.exists():
return False, f"TTW .mpi file not found: {ttw_mpi_path}"
if not ttw_mpi_path.is_file():
return False, f"TTW .mpi path is not a file: {ttw_mpi_path}"
if ttw_mpi_path.suffix.lower() != '.mpi':
return False, f"TTW path does not have .mpi extension: {ttw_mpi_path}"
if not ttw_output_path.exists():
try:
ttw_output_path.mkdir(parents=True, exist_ok=True)
except Exception as e:
return False, f"Failed to create output directory: {e}"
# Check installation
if not self.ttw_installer_installed:
# Try to install automatically
self.logger.info("TTW_Linux_Installer not found, attempting to install...")
success, message = self.install_ttw_installer()
if not success:
return False, f"TTW_Linux_Installer not installed and auto-install failed: {message}"
if not self.ttw_installer_executable_path or not self.ttw_installer_executable_path.is_file():
return False, "TTW_Linux_Installer executable not found"
# Detect game paths
required_games = ['Fallout 3', 'Fallout New Vegas']
detected_games = self.path_handler.find_vanilla_game_paths()
missing_games = [game for game in required_games if game not in detected_games]
if missing_games:
return False, f"Missing required games: {', '.join(missing_games)}. TTW requires both Fallout 3 and Fallout New Vegas."
fallout3_path = detected_games.get('Fallout 3')
falloutnv_path = detected_games.get('Fallout New Vegas')
if not fallout3_path or not falloutnv_path:
return False, "Could not detect Fallout 3 or Fallout New Vegas installation paths"
# Construct command - run in CLI mode with arguments
cmd = [
str(self.ttw_installer_executable_path),
"--fo3", str(fallout3_path),
"--fnv", str(falloutnv_path),
"--mpi", str(ttw_mpi_path),
"--output", str(ttw_output_path),
"--start"
]
self.logger.info(f"Executing TTW_Linux_Installer: {' '.join(cmd)}")
try:
env = get_clean_subprocess_env()
process = subprocess.Popen(
cmd,
cwd=str(self.ttw_installer_dir),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
universal_newlines=True
)
# Stream output to logger
if process.stdout:
for line in process.stdout:
line = line.rstrip()
if line:
self.logger.info(f"TTW_Linux_Installer: {line}")
process.wait()
ret = process.returncode
if ret == 0:
self.logger.info("TTW installation completed successfully.")
return True, "TTW installation completed successfully!"
else:
self.logger.error(f"TTW installation process returned non-zero exit code: {ret}")
return False, f"TTW installation failed with exit code {ret}"
except Exception as e:
self.logger.error(f"Error executing TTW_Linux_Installer: {e}", exc_info=True)
return False, f"Error executing TTW_Linux_Installer: {e}"
def start_ttw_installation(self, ttw_mpi_path: Path, ttw_output_path: Path, output_file: Path):
"""Start TTW installation process (non-blocking).
Starts the TTW_Linux_Installer subprocess with output redirected to a file.
Returns immediately with process handle. Caller should poll process and read output file.
Args:
ttw_mpi_path: Path to TTW .mpi file
ttw_output_path: Target installation directory
output_file: Path to file where stdout/stderr will be written
Returns:
(process: subprocess.Popen, error_message: str) - process is None if failed
"""
self.logger.info("Starting TTW installation (non-blocking mode)")
# Validate parameters
if not ttw_mpi_path or not ttw_output_path:
return None, "Missing required parameters: ttw_mpi_path and ttw_output_path are required"
ttw_mpi_path = Path(ttw_mpi_path)
ttw_output_path = Path(ttw_output_path)
# Validate paths
if not ttw_mpi_path.exists():
return None, f"TTW .mpi file not found: {ttw_mpi_path}"
if not ttw_mpi_path.is_file():
return None, f"TTW .mpi path is not a file: {ttw_mpi_path}"
if ttw_mpi_path.suffix.lower() != '.mpi':
return None, f"TTW path does not have .mpi extension: {ttw_mpi_path}"
if not ttw_output_path.exists():
try:
ttw_output_path.mkdir(parents=True, exist_ok=True)
except Exception as e:
return None, f"Failed to create output directory: {e}"
# Check installation
if not self.ttw_installer_installed:
self.logger.info("TTW_Linux_Installer not found, attempting to install...")
success, message = self.install_ttw_installer()
if not success:
return None, f"TTW_Linux_Installer not installed and auto-install failed: {message}"
if not self.ttw_installer_executable_path or not self.ttw_installer_executable_path.is_file():
return None, "TTW_Linux_Installer executable not found"
# Detect game paths
required_games = ['Fallout 3', 'Fallout New Vegas']
detected_games = self.path_handler.find_vanilla_game_paths()
missing_games = [game for game in required_games if game not in detected_games]
if missing_games:
return None, f"Missing required games: {', '.join(missing_games)}. TTW requires both Fallout 3 and Fallout New Vegas."
fallout3_path = detected_games.get('Fallout 3')
falloutnv_path = detected_games.get('Fallout New Vegas')
if not fallout3_path or not falloutnv_path:
return None, "Could not detect Fallout 3 or Fallout New Vegas installation paths"
# Construct command
cmd = [
str(self.ttw_installer_executable_path),
"--fo3", str(fallout3_path),
"--fnv", str(falloutnv_path),
"--mpi", str(ttw_mpi_path),
"--output", str(ttw_output_path),
"--start"
]
self.logger.info(f"Executing TTW_Linux_Installer: {' '.join(cmd)}")
try:
env = get_clean_subprocess_env()
# Ensure lz4 is in PATH (critical for TTW_Linux_Installer)
import shutil
appdir = env.get('APPDIR')
if appdir:
tools_dir = os.path.join(appdir, 'opt', 'jackify', 'tools')
bundled_lz4 = os.path.join(tools_dir, 'lz4')
if os.path.exists(bundled_lz4) and os.access(bundled_lz4, os.X_OK):
current_path = env.get('PATH', '')
path_parts = [p for p in current_path.split(':') if p and p != tools_dir]
env['PATH'] = f"{tools_dir}:{':'.join(path_parts)}"
self.logger.info(f"Added bundled lz4 to PATH: {tools_dir}")
# Verify lz4 is available
lz4_path = shutil.which('lz4', path=env.get('PATH', ''))
if not lz4_path:
system_lz4 = shutil.which('lz4')
if system_lz4:
lz4_dir = os.path.dirname(system_lz4)
env['PATH'] = f"{lz4_dir}:{env.get('PATH', '')}"
self.logger.info(f"Added system lz4 to PATH: {lz4_dir}")
else:
return None, "lz4 is required but not found in PATH"
# Open output file for writing
output_fh = open(output_file, 'w', encoding='utf-8', buffering=1)
# Start process with output redirected to file
process = subprocess.Popen(
cmd,
cwd=str(self.ttw_installer_dir),
env=env,
stdout=output_fh,
stderr=subprocess.STDOUT,
bufsize=1
)
self.logger.info(f"TTW_Linux_Installer process started (PID: {process.pid}), output to {output_file}")
# Store file handle so it can be closed later
process._output_fh = output_fh
return process, None
except Exception as e:
self.logger.error(f"Error starting TTW_Linux_Installer: {e}", exc_info=True)
return None, f"Error starting TTW_Linux_Installer: {e}"
@staticmethod
def cleanup_ttw_process(process):
"""Clean up after TTW installation process.
Closes file handles and ensures process is terminated properly.
Args:
process: subprocess.Popen object from start_ttw_installation()
"""
if process:
# Close output file handle if attached
if hasattr(process, '_output_fh'):
try:
process._output_fh.close()
except Exception:
pass
# Terminate if still running
if process.poll() is None:
try:
process.terminate()
process.wait(timeout=5)
except Exception:
try:
process.kill()
except Exception:
pass
def install_ttw_backend_with_output_stream(self, ttw_mpi_path: Path, ttw_output_path: Path, output_callback=None):
"""Install TTW with streaming output for GUI (DEPRECATED - use start_ttw_installation instead).
Args:
ttw_mpi_path: Path to TTW .mpi file
ttw_output_path: Target installation directory
output_callback: Optional callback function(line: str) for real-time output
Returns:
(success: bool, message: str)
"""
self.logger.info("Starting Tale of Two Wastelands installation via TTW_Linux_Installer (with output stream)")
# Validate parameters (same as install_ttw_backend)
if not ttw_mpi_path or not ttw_output_path:
return False, "Missing required parameters: ttw_mpi_path and ttw_output_path are required"
ttw_mpi_path = Path(ttw_mpi_path)
ttw_output_path = Path(ttw_output_path)
# Validate paths
if not ttw_mpi_path.exists():
return False, f"TTW .mpi file not found: {ttw_mpi_path}"
if not ttw_mpi_path.is_file():
return False, f"TTW .mpi path is not a file: {ttw_mpi_path}"
if ttw_mpi_path.suffix.lower() != '.mpi':
return False, f"TTW path does not have .mpi extension: {ttw_mpi_path}"
if not ttw_output_path.exists():
try:
ttw_output_path.mkdir(parents=True, exist_ok=True)
except Exception as e:
return False, f"Failed to create output directory: {e}"
# Check installation
if not self.ttw_installer_installed:
if output_callback:
output_callback("TTW_Linux_Installer not found, installing...")
self.logger.info("TTW_Linux_Installer not found, attempting to install...")
success, message = self.install_ttw_installer()
if not success:
return False, f"TTW_Linux_Installer not installed and auto-install failed: {message}"
if not self.ttw_installer_executable_path or not self.ttw_installer_executable_path.is_file():
return False, "TTW_Linux_Installer executable not found"
# Detect game paths
required_games = ['Fallout 3', 'Fallout New Vegas']
detected_games = self.path_handler.find_vanilla_game_paths()
missing_games = [game for game in required_games if game not in detected_games]
if missing_games:
return False, f"Missing required games: {', '.join(missing_games)}. TTW requires both Fallout 3 and Fallout New Vegas."
fallout3_path = detected_games.get('Fallout 3')
falloutnv_path = detected_games.get('Fallout New Vegas')
if not fallout3_path or not falloutnv_path:
return False, "Could not detect Fallout 3 or Fallout New Vegas installation paths"
# Construct command
cmd = [
str(self.ttw_installer_executable_path),
"--fo3", str(fallout3_path),
"--fnv", str(falloutnv_path),
"--mpi", str(ttw_mpi_path),
"--output", str(ttw_output_path),
"--start"
]
self.logger.info(f"Executing TTW_Linux_Installer: {' '.join(cmd)}")
try:
env = get_clean_subprocess_env()
process = subprocess.Popen(
cmd,
cwd=str(self.ttw_installer_dir),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
universal_newlines=True
)
# Stream output to both logger and callback
if process.stdout:
for line in process.stdout:
line = line.rstrip()
if line:
self.logger.info(f"TTW_Linux_Installer: {line}")
if output_callback:
output_callback(line)
process.wait()
ret = process.returncode
if ret == 0:
self.logger.info("TTW installation completed successfully.")
return True, "TTW installation completed successfully!"
else:
self.logger.error(f"TTW installation process returned non-zero exit code: {ret}")
return False, f"TTW installation failed with exit code {ret}"
except Exception as e:
self.logger.error(f"Error executing TTW_Linux_Installer: {e}", exc_info=True)
return False, f"Error executing TTW_Linux_Installer: {e}"
@staticmethod
def integrate_ttw_into_modlist(ttw_output_path: Path, modlist_install_dir: Path, ttw_version: str) -> bool:
"""Integrate TTW output into a modlist's MO2 structure
This method:
1. Copies TTW output to the modlist's mods folder
2. Updates modlist.txt for all profiles
3. Updates plugins.txt with TTW ESMs in correct order
Args:
ttw_output_path: Path to TTW output directory
modlist_install_dir: Path to modlist installation directory
ttw_version: TTW version string (e.g., "3.4")
Returns:
bool: True if integration successful, False otherwise
"""
logging_handler = LoggingHandler()
logging_handler.rotate_log_for_logger('ttw-install', 'TTW_Install_workflow.log')
logger = logging_handler.setup_logger('ttw-install', 'TTW_Install_workflow.log')
try:
import shutil
# Validate paths
if not ttw_output_path.exists():
logger.error(f"TTW output path does not exist: {ttw_output_path}")
return False
mods_dir = modlist_install_dir / "mods"
profiles_dir = modlist_install_dir / "profiles"
if not mods_dir.exists() or not profiles_dir.exists():
logger.error(f"Invalid modlist directory structure: {modlist_install_dir}")
return False
# Create mod folder name with version
mod_folder_name = f"[NoDelete] Tale of Two Wastelands {ttw_version}" if ttw_version else "[NoDelete] Tale of Two Wastelands"
target_mod_dir = mods_dir / mod_folder_name
# Copy TTW output to mods directory
logger.info(f"Copying TTW output to {target_mod_dir}")
if target_mod_dir.exists():
logger.info(f"Removing existing TTW mod at {target_mod_dir}")
shutil.rmtree(target_mod_dir)
shutil.copytree(ttw_output_path, target_mod_dir)
logger.info("TTW output copied successfully")
# TTW ESMs in correct load order
ttw_esms = [
"Fallout3.esm",
"Anchorage.esm",
"ThePitt.esm",
"BrokenSteel.esm",
"PointLookout.esm",
"Zeta.esm",
"TaleOfTwoWastelands.esm",
"YUPTTW.esm"
]
# Process each profile
for profile_dir in profiles_dir.iterdir():
if not profile_dir.is_dir():
continue
profile_name = profile_dir.name
logger.info(f"Processing profile: {profile_name}")
# Update modlist.txt
modlist_file = profile_dir / "modlist.txt"
if modlist_file.exists():
# Read existing modlist
with open(modlist_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
# Find the TTW placeholder separator and insert BEFORE it
separator_found = False
ttw_mod_line = f"+{mod_folder_name}\n"
new_lines = []
for line in lines:
# Skip existing TTW mod entries (but keep separators and other TTW-related mods)
# Match patterns: "+[NoDelete] Tale of Two Wastelands", "+[NoDelete] TTW", etc.
stripped = line.strip()
if stripped.startswith('+') and '[nodelete]' in stripped.lower():
# Check if it's the main TTW mod (not other TTW-related mods like "TTW Quick Start")
if ('tale of two wastelands' in stripped.lower() and 'quick start' not in stripped.lower() and
'loading wheel' not in stripped.lower()) or stripped.lower().startswith('+[nodelete] ttw '):
logger.info(f"Removing existing TTW mod entry: {stripped}")
continue
# Insert TTW mod BEFORE the placeholder separator (MO2 order is bottom-up)
# Check BEFORE appending so TTW mod appears before separator in file
if "put tale of two wastelands mod here" in line.lower() and "_separator" in line.lower():
new_lines.append(ttw_mod_line)
separator_found = True
logger.info(f"Inserted TTW mod before separator: {line.strip()}")
new_lines.append(line)
# If no separator found, append at the end
if not separator_found:
new_lines.append(ttw_mod_line)
logger.warning(f"No TTW separator found in {profile_name}, appended to end")
# Write back
with open(modlist_file, 'w', encoding='utf-8') as f:
f.writelines(new_lines)
logger.info(f"Updated modlist.txt for {profile_name}")
else:
logger.warning(f"modlist.txt not found for profile {profile_name}")
# Update plugins.txt
plugins_file = profile_dir / "plugins.txt"
if plugins_file.exists():
# Read existing plugins
with open(plugins_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
# Remove any existing TTW ESMs
ttw_esm_set = set(esm.lower() for esm in ttw_esms)
lines = [line for line in lines if line.strip().lower() not in ttw_esm_set]
# Find CaravanPack.esm and insert TTW ESMs after it
insert_index = None
for i, line in enumerate(lines):
if line.strip().lower() == "caravanpack.esm":
insert_index = i + 1
break
if insert_index is not None:
# Insert TTW ESMs in correct order
for esm in reversed(ttw_esms):
lines.insert(insert_index, f"{esm}\n")
else:
logger.warning(f"CaravanPack.esm not found in {profile_name}, appending TTW ESMs to end")
for esm in ttw_esms:
lines.append(f"{esm}\n")
# Write back
with open(plugins_file, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info(f"Updated plugins.txt for {profile_name}")
else:
logger.warning(f"plugins.txt not found for profile {profile_name}")
logger.info("TTW integration completed successfully")
return True
except Exception as e:
logger.error(f"Error integrating TTW into modlist: {e}", exc_info=True)
return False

View File

@@ -1016,8 +1016,8 @@ class WineUtils:
seen_names.add(version['name'])
if unique_versions:
logger.info(f"Found {len(unique_versions)} total Proton version(s)")
logger.info(f"Best available: {unique_versions[0]['name']} ({unique_versions[0]['type']})")
logger.debug(f"Found {len(unique_versions)} total Proton version(s)")
logger.debug(f"Best available: {unique_versions[0]['name']} ({unique_versions[0]['type']})")
else:
logger.warning("No Proton versions found")

View File

@@ -137,6 +137,8 @@ class WinetricksHandler:
from ..handlers.wine_utils import WineUtils
config = ConfigHandler()
# Use Install Proton for component installation/texture processing
# get_proton_path() returns the Install Proton path
user_proton_path = config.get_proton_path()
# If user selected a specific Proton, try that first
@@ -162,21 +164,27 @@ class WinetricksHandler:
else:
self.logger.warning(f"User-selected Proton no longer exists: {user_proton_path}")
# Fall back to auto-detection if user selection failed or is 'auto'
# Only auto-detect if user explicitly chose 'auto'
if not wine_binary:
self.logger.info("Falling back to automatic Proton detection")
best_proton = WineUtils.select_best_proton()
if best_proton:
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
self.logger.info(f"Auto-selected Proton: {best_proton['name']} at {best_proton['path']}")
else:
# Enhanced debugging for Proton detection failure
self.logger.error("Auto-detection failed - no Proton versions found")
available_versions = WineUtils.scan_all_proton_versions()
if available_versions:
self.logger.error(f"Available Proton versions: {[v['name'] for v in available_versions]}")
if user_proton_path == 'auto':
self.logger.info("Auto-detecting Proton (user selected 'auto')")
best_proton = WineUtils.select_best_proton()
if best_proton:
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
self.logger.info(f"Auto-selected Proton: {best_proton['name']} at {best_proton['path']}")
else:
self.logger.error("No Proton versions detected in standard Steam locations")
# Enhanced debugging for Proton detection failure
self.logger.error("Auto-detection failed - no Proton versions found")
available_versions = WineUtils.scan_all_proton_versions()
if available_versions:
self.logger.error(f"Available Proton versions: {[v['name'] for v in available_versions]}")
else:
self.logger.error("No Proton versions detected in standard Steam locations")
else:
# User selected a specific Proton but validation failed - this is an ERROR
self.logger.error(f"Cannot use configured Proton: {user_proton_path}")
self.logger.error("Please check Settings and ensure the Proton version still exists")
return False
if not wine_binary:
self.logger.error("Cannot run winetricks: No compatible Proton version found")
@@ -269,27 +277,23 @@ class WinetricksHandler:
# Check user preference for component installation method
from ..handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
use_winetricks = config_handler.get('use_winetricks_for_components', True)
# Get component installation method with migration
method = config_handler.get('component_installation_method', 'winetricks')
# Legacy .NET Framework versions that are problematic in Wine/Proton
# DISABLED in v0.1.6.2: Universal registry fixes replace dotnet4.x installation
# legacy_dotnet_versions = ['dotnet40', 'dotnet472', 'dotnet48']
legacy_dotnet_versions = [] # ALL dotnet4.x versions disabled - universal registry fixes handle compatibility
# Migrate bundled_protontricks to system_protontricks (no longer supported)
if method == 'bundled_protontricks':
self.logger.warning("Bundled protontricks no longer supported, migrating to system_protontricks")
method = 'system_protontricks'
config_handler.set('component_installation_method', 'system_protontricks')
# Check if any legacy .NET Framework versions are present
has_legacy_dotnet = any(comp in components_to_install for comp in legacy_dotnet_versions)
# Choose installation method based on user preference and components
# HYBRID APPROACH MOSTLY DISABLED: dotnet40/dotnet472 replaced with universal registry fixes
if has_legacy_dotnet:
legacy_found = [comp for comp in legacy_dotnet_versions if comp in components_to_install]
self.logger.info(f"Using hybrid approach: protontricks for legacy .NET versions {legacy_found} (reliable), {'winetricks' if use_winetricks else 'protontricks'} for other components")
return self._install_components_hybrid_approach(components_to_install, wineprefix, game_var, use_winetricks)
elif not use_winetricks:
self.logger.info("Using legacy approach: protontricks for all components")
# Choose installation method based on user preference
if method == 'system_protontricks':
self.logger.info("Using system protontricks for all components")
return self._install_components_protontricks_only(components_to_install, wineprefix, game_var)
# else: method == 'winetricks' (default behavior continues below)
# For non-dotnet40 installations, install all components together (faster)
# Install all components together with winetricks (faster)
max_attempts = 3
winetricks_failed = False
last_error_details = None
@@ -361,23 +365,6 @@ class WinetricksHandler:
self.logger.error(f"Component verification failed (Attempt {attempt}/{max_attempts})")
# Continue to retry
else:
# Special handling for dotnet40 verification issue (mimics protontricks behavior)
if "dotnet40" in components_to_install and "ngen.exe not found" in result.stderr:
self.logger.warning("dotnet40 verification warning (common in Steam Proton prefixes)")
self.logger.info("Checking if dotnet40 was actually installed...")
# Check if dotnet40 appears in winetricks.log (indicates successful installation)
log_path = os.path.join(wineprefix, 'winetricks.log')
if os.path.exists(log_path):
try:
with open(log_path, 'r') as f:
log_content = f.read()
if 'dotnet40' in log_content:
self.logger.info("dotnet40 found in winetricks.log - installation succeeded despite verification warning")
return True
except Exception as e:
self.logger.warning(f"Could not read winetricks.log: {e}")
# Store detailed error information for fallback diagnostics
last_error_details = {
'returncode': result.returncode,
@@ -463,7 +450,8 @@ class WinetricksHandler:
# Check if protontricks is available for fallback using centralized handler
try:
from .protontricks_handler import ProtontricksHandler
protontricks_handler = ProtontricksHandler()
steamdeck = os.path.exists('/home/deck')
protontricks_handler = ProtontricksHandler(steamdeck)
protontricks_available = protontricks_handler.is_available()
if protontricks_available:
@@ -493,103 +481,24 @@ class WinetricksHandler:
def _reorder_components_for_installation(self, components: list) -> list:
"""
Reorder components for proper installation sequence.
Critical: dotnet40 must be installed before dotnet6/dotnet7 to avoid conflicts.
Reorder components for proper installation sequence if needed.
Currently returns components in original order.
"""
# Simple reordering: dotnet40 first, then everything else
reordered = []
# Add dotnet40 first if it exists
if "dotnet40" in components:
reordered.append("dotnet40")
# Add all other components in original order
for component in components:
if component != "dotnet40":
reordered.append(component)
if reordered != components:
self.logger.info(f"Reordered for dotnet40 compatibility: {reordered}")
return reordered
def _prepare_prefix_for_dotnet(self, wineprefix: str, wine_binary: str) -> bool:
"""
Prepare the Wine prefix for .NET installation by mimicking protontricks preprocessing.
This removes mono components and specific symlinks that interfere with .NET installation.
"""
try:
env = os.environ.copy()
env['WINEDEBUG'] = '-all'
env['WINEPREFIX'] = wineprefix
# Step 1: Remove mono components (mimics protontricks behavior)
self.logger.info("Preparing prefix for .NET installation: removing mono")
mono_result = subprocess.run([
self.winetricks_path,
'-q',
'remove_mono'
], env=env, capture_output=True, text=True, timeout=300)
if mono_result.returncode != 0:
self.logger.warning(f"Mono removal warning (non-critical): {mono_result.stderr}")
# Step 2: Set Windows version to XP (protontricks uses winxp for dotnet40)
self.logger.info("Setting Windows version to XP for .NET compatibility")
winxp_result = subprocess.run([
self.winetricks_path,
'-q',
'winxp'
], env=env, capture_output=True, text=True, timeout=300)
if winxp_result.returncode != 0:
self.logger.warning(f"Windows XP setting warning: {winxp_result.stderr}")
# Step 3: Remove mscoree.dll symlinks (critical for .NET installation)
self.logger.info("Removing problematic mscoree.dll symlinks")
dosdevices_path = os.path.join(wineprefix, 'dosdevices', 'c:')
mscoree_paths = [
os.path.join(dosdevices_path, 'windows', 'syswow64', 'mscoree.dll'),
os.path.join(dosdevices_path, 'windows', 'system32', 'mscoree.dll')
]
for dll_path in mscoree_paths:
if os.path.exists(dll_path) or os.path.islink(dll_path):
try:
os.remove(dll_path)
self.logger.debug(f"Removed symlink: {dll_path}")
except Exception as e:
self.logger.warning(f"Could not remove {dll_path}: {e}")
self.logger.info("Prefix preparation complete for .NET installation")
return True
except Exception as e:
self.logger.error(f"Error preparing prefix for .NET: {e}")
return False
return components
def _install_components_separately(self, components: list, wineprefix: str, wine_binary: str, base_env: dict) -> bool:
"""
Install components separately like protontricks does.
This is necessary when dotnet40 is present to avoid component conflicts.
Install components separately for maximum compatibility.
"""
self.logger.info(f"Installing {len(components)} components separately (protontricks style)")
self.logger.info(f"Installing {len(components)} components separately")
for i, component in enumerate(components, 1):
self.logger.info(f"Installing component {i}/{len(components)}: {component}")
# Prepare environment for this component
env = base_env.copy()
# Special preprocessing for dotnet40 only
if component == "dotnet40":
self.logger.info("Applying dotnet40 preprocessing")
if not self._prepare_prefix_for_dotnet(wineprefix, wine_binary):
self.logger.error("Failed to prepare prefix for dotnet40")
return False
else:
# For non-dotnet40 components, install in standard mode (Windows 10 will be set after all components)
self.logger.debug(f"Installing {component} in standard mode")
env['WINEPREFIX'] = wineprefix
env['WINE'] = wine_binary
# Install this component
max_attempts = 3
@@ -602,9 +511,6 @@ class WinetricksHandler:
try:
cmd = [self.winetricks_path, '--unattended', component]
env['WINEPREFIX'] = wineprefix
env['WINE'] = wine_binary
self.logger.debug(f"Running: {' '.join(cmd)}")
result = subprocess.run(
@@ -620,22 +526,6 @@ class WinetricksHandler:
component_success = True
break
else:
# Special handling for dotnet40 verification issue
if component == "dotnet40" and "ngen.exe not found" in result.stderr:
self.logger.warning("dotnet40 verification warning (expected in Steam Proton)")
# Check winetricks.log for actual success
log_path = os.path.join(wineprefix, 'winetricks.log')
if os.path.exists(log_path):
try:
with open(log_path, 'r') as f:
if 'dotnet40' in f.read():
self.logger.info("dotnet40 confirmed in winetricks.log")
component_success = True
break
except Exception as e:
self.logger.warning(f"Could not read winetricks.log: {e}")
self.logger.error(f"{component} failed (attempt {attempt}): {result.stderr.strip()}")
self.logger.debug(f"Full stdout for {component}: {result.stdout.strip()}")
@@ -647,121 +537,10 @@ class WinetricksHandler:
return False
self.logger.info("All components installed successfully using separate sessions")
# Set Windows 10 mode after all component installation (matches legacy script timing)
# Set Windows 10 mode after all component installation
self._set_windows_10_mode(wineprefix, env.get('WINE', ''))
return True
def _install_components_hybrid_approach(self, components: list, wineprefix: str, game_var: str, use_winetricks: bool = True) -> bool:
"""
Hybrid approach: Install legacy .NET Framework versions with protontricks (reliable),
then install remaining components with winetricks OR protontricks based on user preference.
Args:
components: List of all components to install
wineprefix: Wine prefix path
game_var: Game variable for AppID detection
use_winetricks: Whether to use winetricks for non-legacy components
Returns:
bool: True if all installations succeeded, False otherwise
"""
self.logger.info("Starting hybrid installation approach")
# Legacy .NET Framework versions that need protontricks
legacy_dotnet_versions = ['dotnet40', 'dotnet472', 'dotnet48']
# Separate legacy .NET (protontricks) from other components (winetricks)
protontricks_components = [comp for comp in components if comp in legacy_dotnet_versions]
other_components = [comp for comp in components if comp not in legacy_dotnet_versions]
self.logger.info(f"Protontricks components: {protontricks_components}")
self.logger.info(f"Other components: {other_components}")
# Step 1: Install legacy .NET Framework versions with protontricks if present
if protontricks_components:
self.logger.info(f"Installing legacy .NET versions {protontricks_components} using protontricks...")
if not self._install_legacy_dotnet_with_protontricks(protontricks_components, wineprefix, game_var):
self.logger.error(f"Failed to install {protontricks_components} with protontricks")
return False
self.logger.info(f"{protontricks_components} installation completed successfully with protontricks")
# Step 2: Install remaining components if any
if other_components:
if use_winetricks:
self.logger.info(f"Installing remaining components with winetricks: {other_components}")
# Use existing winetricks logic for other components
env = self._prepare_winetricks_environment(wineprefix)
if not env:
return False
return self._install_components_with_winetricks(other_components, wineprefix, env)
else:
self.logger.info(f"Installing remaining components with protontricks: {other_components}")
return self._install_components_protontricks_only(other_components, wineprefix, game_var)
self.logger.info("Hybrid component installation completed successfully")
# Set Windows 10 mode after all component installation (matches legacy script timing)
wine_binary = self._get_wine_binary_for_prefix(wineprefix)
self._set_windows_10_mode(wineprefix, wine_binary)
return True
def _install_legacy_dotnet_with_protontricks(self, legacy_components: list, wineprefix: str, game_var: str) -> bool:
"""
Install legacy .NET Framework versions using protontricks (known to work more reliably).
Args:
legacy_components: List of legacy .NET components to install (dotnet40, dotnet472, dotnet48)
wineprefix: Wine prefix path
game_var: Game variable for AppID detection
Returns:
bool: True if installation succeeded, False otherwise
"""
try:
# Extract AppID from wineprefix path (e.g., /path/to/compatdata/123456789/pfx -> 123456789)
appid = None
if 'compatdata' in wineprefix:
# Standard Steam compatdata structure
path_parts = Path(wineprefix).parts
for i, part in enumerate(path_parts):
if part == 'compatdata' and i + 1 < len(path_parts):
potential_appid = path_parts[i + 1]
if potential_appid.isdigit():
appid = potential_appid
break
if not appid:
self.logger.error(f"Could not extract AppID from wineprefix path: {wineprefix}")
return False
self.logger.info(f"Using AppID {appid} for protontricks dotnet40 installation")
# Import and use protontricks handler
from .protontricks_handler import ProtontricksHandler
# Determine if we're on Steam Deck (for protontricks handler)
steamdeck = os.path.exists('/home/deck')
protontricks_handler = ProtontricksHandler(steamdeck, logger=self.logger)
# Detect protontricks availability
if not protontricks_handler.detect_protontricks():
self.logger.error(f"Protontricks not available for legacy .NET installation: {legacy_components}")
return False
# Install legacy .NET components using protontricks
success = protontricks_handler.install_wine_components(appid, game_var, legacy_components)
if success:
self.logger.info(f"Legacy .NET components {legacy_components} installed successfully with protontricks")
return True
else:
self.logger.error(f"Legacy .NET components {legacy_components} installation failed with protontricks")
return False
except Exception as e:
self.logger.error(f"Error installing legacy .NET components {legacy_components} with protontricks: {e}", exc_info=True)
return False
def _prepare_winetricks_environment(self, wineprefix: str) -> Optional[dict]:
"""
Prepare the environment for winetricks installation.
@@ -799,9 +578,15 @@ class WinetricksHandler:
wine_binary = ge_proton_wine
if not wine_binary:
best_proton = WineUtils.select_best_proton()
if best_proton:
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
if user_proton_path == 'auto':
self.logger.info("Auto-detecting Proton (user selected 'auto')")
best_proton = WineUtils.select_best_proton()
if best_proton:
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
else:
# User selected a specific Proton but validation failed
self.logger.error(f"Cannot prepare winetricks environment: configured Proton not found: {user_proton_path}")
return None
if not wine_binary or not (os.path.exists(wine_binary) and os.access(wine_binary, os.X_OK)):
self.logger.error(f"Cannot prepare winetricks environment: No compatible Proton found")
@@ -915,11 +700,16 @@ class WinetricksHandler:
def _install_components_protontricks_only(self, components: list, wineprefix: str, game_var: str) -> bool:
"""
Legacy approach: Install all components using protontricks only.
Install all components using protontricks only.
This matches the behavior of the original bash script.
Args:
components: List of components to install
wineprefix: Path to wine prefix
game_var: Game variable name
"""
try:
self.logger.info(f"Installing all components with protontricks (legacy method): {components}")
self.logger.info(f"Installing all components with system protontricks: {components}")
# Import protontricks handler
from ..handlers.protontricks_handler import ProtontricksHandler
@@ -1013,11 +803,17 @@ class WinetricksHandler:
elif os.path.exists(ge_proton_wine):
wine_binary = ge_proton_wine
# Fall back to auto-detection if user selection failed or is 'auto'
# Only auto-detect if user explicitly chose 'auto'
if not wine_binary:
best_proton = WineUtils.select_best_proton()
if best_proton:
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
if user_proton_path == 'auto':
self.logger.info("Auto-detecting Proton (user selected 'auto')")
best_proton = WineUtils.select_best_proton()
if best_proton:
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
else:
# User selected a specific Proton but validation failed
self.logger.error(f"Configured Proton not found: {user_proton_path}")
return ""
return wine_binary if wine_binary else ""
except Exception as e: