Sync from development - prepare for v0.3.0

This commit is contained in:
Omni
2026-02-07 18:26:54 +00:00
parent b55e1cf768
commit 12294d3186
169 changed files with 31749 additions and 33649 deletions

View File

@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
Wabbajack modlists natively on Linux systems.
"""
__version__ = "0.2.2.2"
__version__ = "0.3.0"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,546 @@
"""CLI configuration phase methods for ModlistInstallCLI (Mixin)."""
import logging
import os
import subprocess
import sys
import time
from pathlib import Path
from ..handlers.ui_colors import (
COLOR_PROMPT,
COLOR_RESET,
COLOR_INFO,
COLOR_ERROR,
COLOR_SUCCESS,
COLOR_WARNING,
)
logger = logging.getLogger(__name__)
class ModlistOperationsConfigurationCLIMixin:
"""Mixin providing CLI configuration phase methods."""
def configuration_phase(self):
"""
Run the configuration phase: execute the Linux-native Jackify Install Engine.
"""
from .modlist_operations import get_jackify_engine_path
print(f"\n{COLOR_PROMPT}--- Configuration Phase: Installing Modlist ---{COLOR_RESET}")
start_time = time.time()
from jackify.shared.paths import get_jackify_logs_dir
log_dir = get_jackify_logs_dir()
log_dir.mkdir(parents=True, exist_ok=True)
workflow_log_path = log_dir / "Modlist_Install_workflow.log"
max_logs = 3
max_size = 1024 * 1024
if workflow_log_path.exists() and workflow_log_path.stat().st_size > max_size:
for i in range(max_logs, 0, -1):
prev = log_dir / f"Modlist_Install_workflow.log.{i-1}" if i > 1 else workflow_log_path
dest = log_dir / f"Modlist_Install_workflow.log.{i}"
if prev.exists():
if dest.exists():
dest.unlink()
prev.rename(dest)
workflow_log = open(workflow_log_path, 'a')
class TeeStdout:
def __init__(self, *files):
self.files = files
def write(self, data):
for f in self.files:
f.write(data)
f.flush()
def flush(self):
for f in self.files:
f.flush()
orig_stdout, orig_stderr = sys.stdout, sys.stderr
sys.stdout = TeeStdout(sys.stdout, workflow_log)
sys.stderr = TeeStdout(sys.stderr, workflow_log)
try:
install_dir_context = self.context['install_dir']
if isinstance(install_dir_context, tuple):
actual_install_path = Path(install_dir_context[0])
if install_dir_context[1]:
self.logger.info(f"Creating install directory as it was marked for creation: {actual_install_path}")
actual_install_path.mkdir(parents=True, exist_ok=True)
else:
actual_install_path = Path(install_dir_context)
install_dir_str = str(actual_install_path)
self.logger.debug(f"Processed install directory for engine: {install_dir_str}")
download_dir_context = self.context['download_dir']
if isinstance(download_dir_context, tuple):
actual_download_path = Path(download_dir_context[0])
if download_dir_context[1]:
self.logger.info(f"Creating download directory as it was marked for creation: {actual_download_path}")
actual_download_path.mkdir(parents=True, exist_ok=True)
else:
actual_download_path = Path(download_dir_context)
download_dir_str = str(actual_download_path)
self.logger.debug(f"Processed download directory for engine: {download_dir_str}")
modlist_arg = self.context.get('modlist_value') or self.context.get('machineid')
machineid = self.context.get('machineid')
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
current_api_key, current_oauth_info = auth_service.get_auth_for_engine()
api_key = current_api_key or self.context.get('nexus_api_key')
oauth_info = current_oauth_info or self.context.get('nexus_oauth_info')
engine_path = get_jackify_engine_path()
engine_dir = os.path.dirname(engine_path)
if not os.path.isfile(engine_path) or not os.access(engine_path, os.X_OK):
print(f"{COLOR_ERROR}Jackify Install Engine not found or not executable at: {engine_path}{COLOR_RESET}")
return
if os.environ.get('JACKIFY_GUI_MODE') == '1':
if not self.context.get('modlist_source'):
self.context['modlist_source'] = 'identifier'
if not self.context.get('modlist_value'):
self.logger.error("modlist_value is missing in context for GUI workflow!")
return
cmd = [engine_path, 'install', '--show-file-progress']
modlist_value = self.context.get('modlist_value')
if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value):
cmd += ['-w', modlist_value]
elif modlist_value:
cmd += ['-m', modlist_value]
elif self.context.get('machineid'):
cmd += ['-m', self.context['machineid']]
cmd += ['-o', install_dir_str, '-d', download_dir_str]
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False)
if debug_mode:
cmd.append('--debug')
self.logger.info("Adding --debug flag to jackify-engine")
original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
}
try:
if oauth_info:
os.environ['NEXUS_OAUTH_INFO'] = oauth_info
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
self.logger.debug(f"Set NEXUS_OAUTH_INFO and NEXUS_OAUTH_CLIENT_ID={NexusOAuthService.CLIENT_ID} for engine (supports auto-refresh)")
if api_key:
os.environ['NEXUS_API_KEY'] = api_key
elif api_key:
os.environ['NEXUS_API_KEY'] = api_key
self.logger.debug(f"Set NEXUS_API_KEY for engine (no auto-refresh)")
else:
if 'NEXUS_API_KEY' in os.environ:
del os.environ['NEXUS_API_KEY']
if 'NEXUS_OAUTH_INFO' in os.environ:
del os.environ['NEXUS_OAUTH_INFO']
if 'NEXUS_OAUTH_CLIENT_ID' in os.environ:
del os.environ['NEXUS_OAUTH_CLIENT_ID']
self.logger.debug(f"No Nexus auth available, cleared inherited env vars")
os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1"
self.logger.debug(f"Temporarily set os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = '1' for engine call.")
self.logger.info("Environment prepared for jackify-engine install process by modifying os.environ.")
self.logger.debug(f"NEXUS_API_KEY in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_API_KEY') else '[NOT SET]'}")
self.logger.debug(f"NEXUS_OAUTH_INFO in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_OAUTH_INFO') else '[NOT SET]'}")
pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd])
print(f"{COLOR_INFO}Launching Jackify Install Engine with command:{COLOR_RESET} {pretty_cmd}")
from jackify.backend.handlers.subprocess_utils import increase_file_descriptor_limit
success, old_limit, new_limit, message = increase_file_descriptor_limit()
if success:
self.logger.debug(f"File descriptor limit: {message}")
else:
self.logger.warning(f"File descriptor limit: {message}")
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
clean_env = get_clean_subprocess_env()
self._current_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=clean_env, cwd=engine_dir)
proc = self._current_process
buffer = b''
while True:
chunk = proc.stdout.read(1)
if not chunk:
break
buffer += chunk
if chunk == b'\n':
line = buffer.decode('utf-8', errors='replace')
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
buffer = b''
continue
print(line, end='')
buffer = b''
elif chunk == b'\r':
line = buffer.decode('utf-8', errors='replace')
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
buffer = b''
continue
print(line, end='')
sys.stdout.flush()
buffer = b''
if buffer:
line = buffer.decode('utf-8', errors='replace')
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
line = ''
if line:
print(line, end='')
proc.wait()
self._current_process = None
if proc.returncode != 0:
print(f"{COLOR_ERROR}Jackify Install Engine exited with code {proc.returncode}.{COLOR_RESET}")
self.logger.error(f"Engine exited with code {proc.returncode}.")
return
self.logger.info(f"Engine completed with code {proc.returncode}.")
except Exception as e:
error_message = str(e)
print(f"{COLOR_ERROR}Error running Jackify Install Engine: {error_message}{COLOR_RESET}\n")
self.logger.error(f"Exception running engine: {error_message}", exc_info=True)
try:
from jackify.backend.services.resource_manager import handle_file_descriptor_error
if any(indicator in error_message.lower() for indicator in ['too many open files', 'emfile', 'resource temporarily unavailable']):
result = handle_file_descriptor_error(error_message, "Jackify Install Engine execution")
if result['auto_fix_success']:
print(f"{COLOR_INFO}File descriptor limit increased automatically. {result['recommendation']}{COLOR_RESET}")
self.logger.info(f"File descriptor limit increased automatically. {result['recommendation']}")
elif result['error_detected']:
print(f"{COLOR_WARNING}File descriptor limit issue detected. {result['recommendation']}{COLOR_RESET}")
self.logger.warning(f"File descriptor limit issue detected but automatic fix failed. {result['recommendation']}")
if result['manual_instructions']:
distro = result['manual_instructions']['distribution']
print(f"{COLOR_INFO}Manual ulimit increase instructions available for {distro} distribution{COLOR_RESET}")
self.logger.info(f"Manual ulimit increase instructions available for {distro} distribution")
except Exception as resource_error:
self.logger.debug(f"Error checking for resource limit issues: {resource_error}")
return
finally:
for key, original_value in original_env_values.items():
current_value_in_os_environ = os.environ.get(key)
display_original_value = f"'[REDACTED]'" if key == 'NEXUS_API_KEY' else f"'{original_value}'"
if original_value is not None:
if current_value_in_os_environ != original_value:
os.environ[key] = original_value
self.logger.debug(f"Restored os.environ['{key}'] to its original value: {display_original_value}.")
else:
os.environ[key] = original_value
self.logger.debug(f"os.environ['{key}'] ('{display_original_value}') matched original value. Ensured restoration.")
else:
if key in os.environ:
self.logger.debug(f"Original os.environ['{key}'] was not set. Removing current value ('{'[REDACTED]' if os.environ.get(key) and key == 'NEXUS_API_KEY' else os.environ.get(key)}') that was set for the call.")
del os.environ[key]
except Exception as e:
error_message = str(e)
print(f"{COLOR_ERROR}Error during installation workflow: {error_message}{COLOR_RESET}\n")
self.logger.error(f"Exception in installation workflow: {error_message}", exc_info=True)
try:
from jackify.backend.services.resource_manager import handle_file_descriptor_error
if any(indicator in error_message.lower() for indicator in ['too many open files', 'emfile', 'resource temporarily unavailable']):
result = handle_file_descriptor_error(error_message, "installation workflow")
if result['auto_fix_success']:
print(f"{COLOR_INFO}File descriptor limit increased automatically. {result['recommendation']}{COLOR_RESET}")
self.logger.info(f"File descriptor limit increased automatically. {result['recommendation']}")
elif result['error_detected']:
print(f"{COLOR_WARNING}File descriptor limit issue detected. {result['recommendation']}{COLOR_RESET}")
self.logger.warning(f"File descriptor limit issue detected but automatic fix failed. {result['recommendation']}")
if result['manual_instructions']:
distro = result['manual_instructions']['distribution']
print(f"{COLOR_INFO}Manual ulimit increase instructions available for {distro} distribution{COLOR_RESET}")
self.logger.info(f"Manual ulimit increase instructions available for {distro} distribution")
except Exception as resource_error:
self.logger.debug(f"Error checking for resource limit issues: {resource_error}")
return
finally:
sys.stdout = orig_stdout
sys.stderr = orig_stderr
workflow_log.close()
elapsed = int(time.time() - start_time)
print(f"\nElapsed time: {elapsed//3600:02d}:{(elapsed%3600)//60:02d}:{elapsed%60:02d} (hh:mm:ss)\n")
print(f"{COLOR_INFO}Your modlist has been installed to: {install_dir_str}{COLOR_RESET}\n")
if self.context.get('machineid') != 'Tuxborn/Tuxborn':
print(f"{COLOR_WARNING}Only Skyrim, Fallout 4, Fallout New Vegas, Oblivion, Starfield, and Oblivion Remastered modlists are compatible with Jackify's post-install configuration. Any modlist can be downloaded/installed, but only these games are supported for automated configuration.{COLOR_RESET}")
self.logger.debug("configuration_phase: Starting post-install game detection...")
modorganizer_ini = os.path.join(install_dir_str, "ModOrganizer.ini")
detected_game = None
self.logger.debug(f"configuration_phase: Looking for ModOrganizer.ini at: {modorganizer_ini}")
if os.path.isfile(modorganizer_ini):
self.logger.debug("configuration_phase: Found ModOrganizer.ini, detecting game...")
from ..handlers.modlist_handler import ModlistHandler
handler = ModlistHandler({}, steamdeck=self.steamdeck)
handler.modlist_ini = modorganizer_ini
handler.modlist_dir = install_dir_str
if handler._detect_game_variables():
detected_game = handler.game_var_full
self.logger.debug(f"configuration_phase: Detected game: {detected_game}")
else:
self.logger.debug("configuration_phase: Failed to detect game variables")
else:
self.logger.debug("configuration_phase: ModOrganizer.ini not found")
supported_games = ["Skyrim Special Edition", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal"]
is_tuxborn = self.context.get('machineid') == 'Tuxborn/Tuxborn'
self.logger.debug(f"configuration_phase: detected_game='{detected_game}', is_tuxborn={is_tuxborn}")
self.logger.debug(f"configuration_phase: Checking condition: (detected_game in supported_games) or is_tuxborn")
self.logger.debug(f"configuration_phase: Result: {(detected_game in supported_games) or is_tuxborn}")
if (detected_game in supported_games) or is_tuxborn:
self.logger.debug("configuration_phase: Entering Steam configuration workflow...")
shortcut_name = self.context.get('modlist_name')
self.logger.debug(f"configuration_phase: shortcut_name from context: '{shortcut_name}'")
if is_tuxborn and not shortcut_name:
self.logger.warning("Tuxborn is true, but shortcut_name (modlist_name in context) is missing. Defaulting to 'Tuxborn Automatic Installer'")
shortcut_name = "Tuxborn Automatic Installer"
elif not shortcut_name:
print("\n" + "-" * 28)
print(f"{COLOR_PROMPT}Please provide a name for the Steam shortcut for '{self.context.get('modlist_name', 'this modlist')}'.{COLOR_RESET}")
raw_shortcut_name = input(f"{COLOR_PROMPT}Steam Shortcut Name (or 'q' to cancel): {COLOR_RESET} ").strip()
if raw_shortcut_name.lower() == 'q' or not raw_shortcut_name:
self.logger.debug("configuration_phase: User cancelled shortcut name input")
return
shortcut_name = raw_shortcut_name
self.logger.debug(f"configuration_phase: Final shortcut_name: '{shortcut_name}'")
is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
self.logger.debug(f"configuration_phase: is_gui_mode={is_gui_mode}")
if not is_gui_mode:
self.logger.debug("configuration_phase: Not in GUI mode, prompting user for configuration...")
print("\n" + "-" * 28)
print(f"{COLOR_PROMPT}Would you like to add '{shortcut_name}' to Steam and configure it now?{COLOR_RESET}")
configure_choice = input(f"{COLOR_PROMPT}Configure now? (Y/n): {COLOR_RESET}").strip().lower()
self.logger.debug(f"configuration_phase: User choice: '{configure_choice}'")
if configure_choice == 'n':
print(f"{COLOR_INFO}Skipping Steam configuration. You can configure it later using 'Configure New Modlist'.{COLOR_RESET}")
self.logger.debug("configuration_phase: User chose to skip Steam configuration")
return
else:
self.logger.debug("configuration_phase: In GUI mode, proceeding automatically...")
self.logger.debug("configuration_phase: Proceeding with Steam configuration...")
if not is_gui_mode:
from jackify.backend.handlers.resolution_handler import ResolutionHandler
resolution_handler = ResolutionHandler()
is_steamdeck = self.steamdeck if hasattr(self, 'steamdeck') else False
selected_resolution = resolution_handler.select_resolution(steamdeck=is_steamdeck)
if selected_resolution:
self.context['resolution'] = selected_resolution
self.logger.info(f"Resolution set to: {selected_resolution}")
self.logger.info(f"Starting Steam configuration for '{shortcut_name}'")
mo2_exe_path = os.path.join(install_dir_str, 'ModOrganizer.exe')
app_id = None
use_automated_prefix = os.environ.get('JACKIFY_USE_AUTOMATED_PREFIX', '1') == '1'
if use_automated_prefix:
print(f"\n{COLOR_INFO}Using automated Steam setup workflow...{COLOR_RESET}")
from ..services.automated_prefix_service import AutomatedPrefixService
prefix_service = AutomatedPrefixService()
start_time = time.time()
def progress_callback(message):
elapsed = time.time() - start_time
hours = int(elapsed // 3600)
minutes = int((elapsed % 3600) // 60)
seconds = int(elapsed % 60)
timestamp = f"[{hours:02d}:{minutes:02d}:{seconds:02d}]"
print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}")
try:
_is_steamdeck = False
if os.path.exists('/etc/os-release'):
with open('/etc/os-release') as f:
if 'steamdeck' in f.read().lower():
_is_steamdeck = True
except Exception:
_is_steamdeck = False
result = prefix_service.run_working_workflow(
shortcut_name, install_dir_str, mo2_exe_path, progress_callback, steamdeck=_is_steamdeck
)
if isinstance(result, tuple) and len(result) == 4:
if result[0] == "CONFLICT":
conflicts = result[1]
print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}")
for i, conflict in enumerate(conflicts, 1):
print(f" {i}. Name: {conflict['name']}")
print(f" Executable: {conflict['exe']}")
print(f" Start Directory: {conflict['startdir']}")
print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}")
print(" * Replace - Remove the existing shortcut and create a new one")
print(" * Cancel - Keep the existing shortcut and stop the installation")
print(" * Skip - Continue without creating a Steam shortcut")
choice = input(f"\n{COLOR_PROMPT}Choose an option (replace/cancel/skip): {COLOR_RESET}").strip().lower()
if choice == 'replace':
print(f"{COLOR_INFO}Replacing existing shortcut...{COLOR_RESET}")
success, app_id = prefix_service.replace_existing_shortcut(shortcut_name, mo2_exe_path, install_dir_str)
if success and app_id:
result = prefix_service.continue_workflow_after_conflict_resolution(
shortcut_name, install_dir_str, mo2_exe_path, app_id, progress_callback
)
if isinstance(result, tuple) and len(result) >= 3:
success, prefix_path, app_id = result[0], result[1], result[2]
else:
success, prefix_path, app_id = False, None, None
else:
success, prefix_path, app_id = False, None, None
elif choice == 'cancel':
print(f"{COLOR_INFO}Cancelling installation.{COLOR_RESET}")
return
elif choice == 'skip':
print(f"{COLOR_INFO}Skipping Steam shortcut creation.{COLOR_RESET}")
success, prefix_path, app_id = True, None, None
else:
print(f"{COLOR_ERROR}Invalid choice. Cancelling.{COLOR_RESET}")
return
else:
success, prefix_path, app_id, last_timestamp = result
elif isinstance(result, tuple) and len(result) == 3:
if result[0] == "CONFLICT":
conflicts = result[1]
print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}")
for i, conflict in enumerate(conflicts, 1):
print(f" {i}. Name: {conflict['name']}")
print(f" Executable: {conflict['exe']}")
print(f" Start Directory: {conflict['startdir']}")
print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}")
print(" * Replace - Remove the existing shortcut and create a new one")
print(" * Cancel - Keep the existing shortcut and stop the installation")
print(" * Skip - Continue without creating a Steam shortcut")
choice = input(f"\n{COLOR_PROMPT}Choose an option (replace/cancel/skip): {COLOR_RESET}").strip().lower()
if choice == 'replace':
print(f"{COLOR_INFO}Replacing existing shortcut...{COLOR_RESET}")
success, app_id = prefix_service.replace_existing_shortcut(shortcut_name, mo2_exe_path, install_dir_str)
if success and app_id:
result = prefix_service.continue_workflow_after_conflict_resolution(
shortcut_name, install_dir_str, mo2_exe_path, app_id, progress_callback
)
if isinstance(result, tuple) and len(result) >= 3:
success, prefix_path, app_id = result[0], result[1], result[2]
else:
success, prefix_path, app_id = False, None, None
else:
success, prefix_path, app_id = False, None, None
elif choice == 'cancel':
print(f"{COLOR_INFO}Cancelling installation.{COLOR_RESET}")
return
elif choice == 'skip':
print(f"{COLOR_INFO}Skipping Steam shortcut creation.{COLOR_RESET}")
success, prefix_path, app_id = True, None, None
else:
print(f"{COLOR_ERROR}Invalid choice. Cancelling.{COLOR_RESET}")
return
else:
success, prefix_path, app_id = result
else:
if result is True:
success, prefix_path, app_id = True, None, None
else:
success, prefix_path, app_id = False, None, None
if success:
print(f"{COLOR_SUCCESS}Automated Steam setup completed successfully!{COLOR_RESET}")
if prefix_path:
print(f"{COLOR_INFO}Proton prefix created at: {prefix_path}{COLOR_RESET}")
if app_id:
print(f"{COLOR_INFO}Steam AppID: {app_id}{COLOR_RESET}")
else:
print(f"{COLOR_ERROR}Automated Steam setup failed. Result: {result}{COLOR_RESET}")
print(f"{COLOR_ERROR}Steam integration was not completed. Please check the logs for details.{COLOR_RESET}")
return
from jackify.backend.services.modlist_service import ModlistService
from jackify.backend.models.modlist import ModlistContext
modlist_context = ModlistContext(
name=shortcut_name,
install_dir=Path(install_dir_str),
download_dir=Path(install_dir_str) / "downloads",
game_type=self.context.get('detected_game', 'Unknown'),
nexus_api_key='',
modlist_value=self.context.get('modlist_value', ''),
modlist_source=self.context.get('modlist_source', 'identifier'),
resolution=self.context.get('resolution'),
mo2_exe_path=Path(mo2_exe_path),
skip_confirmation=True,
engine_installed=True
)
modlist_context.app_id = app_id
modlist_service = ModlistService(self.system_info)
if 'progress_callback' in locals() and progress_callback:
progress_callback("")
progress_callback("=== Configuration Phase ===")
print(f"\n{COLOR_INFO}=== Configuration Phase ==={COLOR_RESET}")
self.logger.info("Running post-installation configuration phase using ModlistService")
configuration_success = modlist_service.configure_modlist_post_steam(modlist_context)
if configuration_success:
print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}")
self.logger.info("Post-installation configuration completed successfully")
else:
print(f"{COLOR_WARNING}Configuration had some issues but completed.{COLOR_RESET}")
self.logger.warning("Post-installation configuration had issues")
else:
print(f"{COLOR_INFO}Modlist installation complete.{COLOR_RESET}")
if detected_game:
print(f"{COLOR_WARNING}Detected game '{detected_game}' is not supported for automated Steam configuration.{COLOR_RESET}")
else:
print(f"{COLOR_WARNING}Could not detect game type from ModOrganizer.ini for automated configuration.{COLOR_RESET}")
print(f"{COLOR_INFO}You may need to manually configure the modlist for Steam/Proton.{COLOR_RESET}")

View File

@@ -0,0 +1,170 @@
"""GUI configuration phase methods for ModlistInstallCLI (Mixin)."""
import logging
import os
logger = logging.getLogger(__name__)
class ModlistOperationsConfigurationGUIMixin:
"""Mixin providing GUI configuration phase methods."""
def configuration_phase_gui_mode(self, context,
progress_callback=None,
manual_steps_callback=None,
completion_callback=None):
"""
GUI-friendly configuration phase that uses callbacks instead of prompts.
This method provides the same functionality as configuration_phase() but
integrates with GUI frontends using Qt callbacks instead of CLI prompts.
Args:
context: Configuration context dict with modlist details
progress_callback: Called with progress messages (str)
manual_steps_callback: Called when manual steps needed (modlist_name, retry_count)
completion_callback: Called when configuration completes (success, message, modlist_name)
"""
try:
from .modlist_operations import _get_user_proton_version
original_gui_mode = os.environ.get('JACKIFY_GUI_MODE')
try:
config_context = {
'name': context.get('modlist_name', ''),
'path': context.get('install_dir', ''),
'mo2_exe_path': context.get('mo2_exe_path', ''),
'modlist_value': context.get('modlist_value'),
'modlist_source': context.get('modlist_source'),
'resolution': context.get('resolution'),
'skip_confirmation': True,
'manual_steps_completed': False
}
existing_app_id = context.get('app_id')
if existing_app_id:
config_context['appid'] = existing_app_id
if progress_callback:
progress_callback(f"Configuring existing modlist with AppID {existing_app_id}...")
from jackify.backend.handlers.menu_handler import ModlistMenuHandler
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
modlist_menu = ModlistMenuHandler(config_handler)
retry_count = 0
max_retries = 3
while retry_count < max_retries:
if progress_callback:
progress_callback("Running modlist configuration...")
result = modlist_menu.run_modlist_configuration_phase(config_context)
if progress_callback:
progress_callback(f"Configuration attempt {retry_count}: {'Success' if result else 'Failed'}")
if result:
if completion_callback:
completion_callback(True, "Configuration completed successfully!", config_context['name'])
return True
else:
retry_count += 1
if retry_count < max_retries:
if progress_callback:
progress_callback(f"Configuration failed on attempt {retry_count}, showing manual steps dialog...")
if manual_steps_callback:
if progress_callback:
progress_callback(f"Calling manual_steps_callback for {config_context['name']}, retry {retry_count}")
manual_steps_callback(config_context['name'], retry_count)
config_context['manual_steps_completed'] = True
else:
if completion_callback:
completion_callback(False, "Manual steps failed after multiple attempts", config_context['name'])
return False
if completion_callback:
completion_callback(False, "Configuration failed", config_context['name'])
return False
else:
from jackify.backend.handlers.menu_handler import ModlistMenuHandler
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
modlist_menu = ModlistMenuHandler(config_handler)
if progress_callback:
progress_callback("Creating Steam shortcut...")
from jackify.backend.services.native_steam_service import NativeSteamService
steam_service = NativeSteamService()
proton_version = _get_user_proton_version()
success, app_id = steam_service.create_shortcut_with_proton(
app_name=config_context['name'],
exe_path=config_context['mo2_exe_path'],
start_dir=os.path.dirname(config_context['mo2_exe_path']),
launch_options="%command%",
tags=["Jackify"],
proton_version=proton_version
)
if not success or not app_id:
if completion_callback:
completion_callback(False, "Failed to create Steam shortcut", config_context['name'])
return False
config_context['appid'] = app_id
if progress_callback:
from jackify.shared.timing import get_timestamp
progress_callback(f"{get_timestamp()} Steam shortcut created successfully")
if progress_callback:
progress_callback("Running modlist configuration...")
if progress_callback:
progress_callback(f"About to call run_modlist_configuration_phase with context: {config_context}")
result = modlist_menu.run_modlist_configuration_phase(config_context)
if progress_callback:
progress_callback(f"run_modlist_configuration_phase returned: {result}")
if result:
if completion_callback:
completion_callback(True, "Configuration completed successfully!", config_context['name'])
return True
else:
if progress_callback:
progress_callback("Configuration failed, manual Steam/Proton setup required")
if manual_steps_callback:
if progress_callback:
progress_callback(f"About to call manual_steps_callback for {config_context['name']}, retry 1")
manual_steps_callback(config_context['name'], 1)
if progress_callback:
progress_callback("manual_steps_callback completed")
return True
if completion_callback:
completion_callback(False, "Configuration failed", config_context['name'])
return False
finally:
if original_gui_mode is not None:
os.environ['JACKIFY_GUI_MODE'] = original_gui_mode
else:
os.environ.pop('JACKIFY_GUI_MODE', None)
except Exception as e:
error_msg = f"Configuration failed: {str(e)}"
if completion_callback:
completion_callback(False, error_msg, context.get('modlist_name', 'Unknown'))
return False

View File

@@ -0,0 +1,368 @@
"""Discovery phase methods for ModlistInstallCLI (Mixin)."""
import logging
import os
from pathlib import Path
from typing import Optional, Dict
from ..handlers.ui_colors import (
COLOR_PROMPT,
COLOR_RESET,
COLOR_INFO,
COLOR_ERROR,
COLOR_SUCCESS,
COLOR_WARNING,
COLOR_SELECTION,
)
from ..handlers.config_handler import ConfigHandler
from jackify.backend.models.configuration import SystemInfo
from jackify.backend.services.modlist_service import ModlistService
logger = logging.getLogger(__name__)
class ModlistOperationsDiscoveryMixin:
"""Mixin providing modlist discovery phase methods."""
def run_discovery_phase(self, context_override=None) -> Optional[Dict]:
"""
Run the discovery phase: prompt for all required info, and validate inputs.
Returns a context dict with all collected info, or None if cancelled.
Accepts context_override for pre-filled values (e.g., for Tuxborn/machineid flow).
"""
from .modlist_operations import get_jackify_engine_path
self.logger.info("Starting modlist discovery phase (restored logic).")
print(f"\n{COLOR_PROMPT}--- Wabbajack Modlist Install: Discovery Phase ---{COLOR_RESET}")
if context_override:
self.context.update(context_override)
if 'resolution' in context_override:
self.context['resolution'] = context_override['resolution']
else:
self.context = {}
is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
if self.context.get('machineid'):
required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key']
else:
required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key', 'game_type']
has_modlist = self.context.get('modlist_value') or self.context.get('machineid')
missing = [k for k in required_keys if not self.context.get(k)]
if is_gui_mode:
if missing or not has_modlist:
self.logger.error(f"Missing required arguments for GUI workflow: {', '.join(missing)}")
if not has_modlist:
self.logger.error("Missing modlist_value or machineid for GUI workflow.")
self.logger.error("This workflow must be fully non-interactive. Please report this as a bug if you see this message.")
return None
self.logger.info("All required context present in GUI mode, skipping prompts.")
return self.context
engine_executable = get_jackify_engine_path()
self.logger.debug(f"Engine executable path: {engine_executable}")
if not os.path.exists(engine_executable):
print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_RESET}")
print(f"{COLOR_INFO}Expected: {engine_executable}{COLOR_RESET}")
return None
engine_dir = os.path.dirname(engine_executable)
if 'machineid' not in self.context:
print("\n" + "-" * 28)
print(f"{COLOR_PROMPT}How would you like to select your modlist?{COLOR_RESET}")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Select from a list of available modlists")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Provide the path to a .wabbajack file on disk")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Cancel and return to previous menu")
source_choice = input(f"{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
self.logger.debug(f"User selected modlist source option: {source_choice}")
if source_choice == '1':
self.context['modlist_source_type'] = 'online_list'
print(f"\n{COLOR_INFO}Fetching available modlists... This may take a moment.{COLOR_RESET}")
try:
is_steamdeck = False
if os.path.exists('/etc/os-release'):
with open('/etc/os-release') as f:
if 'steamdeck' in f.read().lower():
is_steamdeck = True
system_info = SystemInfo(is_steamdeck=is_steamdeck)
modlist_service = ModlistService(system_info)
categories = [
("Skyrim", "skyrim"),
("Fallout 4", "fallout4"),
("Fallout New Vegas", "falloutnv"),
("Oblivion", "oblivion"),
("Starfield", "starfield"),
("Oblivion Remastered", "oblivion_remastered"),
("Other Games", "other")
]
grouped_modlists = {}
for label, key in categories:
grouped_modlists[label] = modlist_service.list_modlists(game_type=key)
selected_modlist_info = None
while not selected_modlist_info:
print(f"\n{COLOR_PROMPT}Select a game category:{COLOR_RESET}")
category_display_map = {}
display_idx = 1
for label, _ in categories:
modlists = grouped_modlists[label]
if label == "Oblivion Remastered" or modlists:
print(f" {COLOR_SELECTION}{display_idx}.{COLOR_RESET} {label} ({len(modlists)} modlists)")
category_display_map[str(display_idx)] = label
display_idx += 1
if display_idx == 1:
print(f"{COLOR_WARNING}No modlists found to display after grouping. Engine output might be empty or filtered entirely.{COLOR_RESET}")
return None
print(f" {COLOR_SELECTION}0.{COLOR_RESET} Cancel")
game_cat_choice = input(f"{COLOR_PROMPT}Enter selection: {COLOR_RESET}").strip()
if game_cat_choice == '0':
self.logger.info("User cancelled game category selection.")
return None
actual_label = category_display_map.get(game_cat_choice)
if not actual_label:
print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}")
continue
modlist_group_for_game = sorted(grouped_modlists[actual_label], key=lambda x: x.id.lower())
print(f"\n{COLOR_SUCCESS}Available Modlists for {actual_label}:{COLOR_RESET}")
for idx, m_detail in enumerate(modlist_group_for_game, 1):
if actual_label == "Other Games":
print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail.id} ({m_detail.game})")
else:
print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail.id}")
print(f" {COLOR_SELECTION}0.{COLOR_RESET} Back to game categories")
while True:
mod_choice_idx_str = input(f"{COLOR_PROMPT}Select modlist (or 0): {COLOR_RESET}").strip()
if mod_choice_idx_str == '0':
break
if mod_choice_idx_str.isdigit():
mod_idx = int(mod_choice_idx_str) - 1
if 0 <= mod_idx < len(modlist_group_for_game):
selected_modlist_info = {
'id': modlist_group_for_game[mod_idx].id,
'game': modlist_group_for_game[mod_idx].game,
'machine_url': getattr(modlist_group_for_game[mod_idx], 'machine_url', modlist_group_for_game[mod_idx].id)
}
self.context['modlist_source'] = 'identifier'
self.context['modlist_value'] = selected_modlist_info.get('machine_url', selected_modlist_info['id'])
self.context['modlist_game'] = selected_modlist_info['game']
self.context['modlist_name_suggestion'] = selected_modlist_info['id'].split('/')[-1]
self.logger.info(f"User selected online modlist: {selected_modlist_info}")
break
else:
print(f"{COLOR_ERROR}Invalid modlist number.{COLOR_RESET}")
else:
print(f"{COLOR_ERROR}Invalid input. Please enter a number.{COLOR_RESET}")
if selected_modlist_info:
break
except Exception as e:
self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True)
print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_RESET}")
return None
elif source_choice == '2':
self.context['modlist_source_type'] = 'local_file'
print(f"\n{COLOR_PROMPT}Please provide the path to your .wabbajack file (tab-completion supported).{COLOR_RESET}")
modlist_path = self.menu_handler.get_existing_file_path(
prompt_message="Enter the path to your .wabbajack file (or 'q' to cancel):",
extension_filter=".wabbajack",
no_header=True
)
if modlist_path is None:
self.logger.info("User cancelled .wabbajack file selection.")
print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}")
return None
self.context['modlist_source'] = 'path'
self.context['modlist_value'] = str(modlist_path)
self.context['modlist_name_suggestion'] = Path(modlist_path).stem
self.logger.info(f"User selected local .wabbajack file: {modlist_path}")
elif source_choice == '0':
self.logger.info("User cancelled modlist source selection.")
print(f"{COLOR_INFO}Returning to previous menu.{COLOR_RESET}")
return None
else:
self.logger.warning(f"Invalid modlist source choice: {source_choice}")
print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}")
return self.run_discovery_phase()
if 'modlist_name' not in self.context or not self.context['modlist_name']:
default_name = self.context.get('modlist_name_suggestion', 'MyModlist')
print("\n" + "-" * 28)
print(f"{COLOR_PROMPT}Enter a name for this modlist installation in Steam.{COLOR_RESET}")
print(f"{COLOR_INFO}(This will be the shortcut name. Default: {default_name}){COLOR_RESET}")
modlist_name_input = input(f"{COLOR_PROMPT}Modlist Name (or 'q' to cancel): {COLOR_RESET}").strip()
if not modlist_name_input:
modlist_name = default_name
elif modlist_name_input.lower() == 'q':
self.logger.info("User cancelled at modlist name prompt.")
return None
else:
modlist_name = modlist_name_input
self.context['modlist_name'] = modlist_name
self.logger.debug(f"Modlist name set to: {self.context['modlist_name']}")
if 'install_dir' not in self.context:
config_handler = ConfigHandler()
base_install_dir = Path(config_handler.get_modlist_install_base_dir())
default_install_dir = base_install_dir / self.context['modlist_name']
print("\n" + "-" * 28)
print(f"{COLOR_PROMPT}Enter the main installation directory for '{self.context['modlist_name']}'.{COLOR_RESET}")
print(f"{COLOR_INFO}(Default: {default_install_dir}){COLOR_RESET}")
install_dir_path = self.menu_handler.get_directory_path(
prompt_message=f"{COLOR_PROMPT}Install directory (or 'q' to cancel, Enter for default): {COLOR_RESET}",
default_path=default_install_dir,
create_if_missing=True,
no_header=True
)
if install_dir_path is None:
self.logger.info("User cancelled at install directory prompt.")
return None
self.context['install_dir'] = install_dir_path
self.logger.debug(f"Install directory context set to: {self.context['install_dir']}")
if 'download_dir' not in self.context:
config_handler = ConfigHandler()
base_download_dir = Path(config_handler.get_modlist_downloads_base_dir())
default_download_dir = base_download_dir / self.context['modlist_name']
print("\n" + "-" * 28)
print(f"{COLOR_PROMPT}Enter the downloads directory for modlist archives.{COLOR_RESET}")
print(f"{COLOR_INFO}(Default: {default_download_dir}){COLOR_RESET}")
download_dir_path = self.menu_handler.get_directory_path(
prompt_message=f"{COLOR_PROMPT}Download directory (or 'q' to cancel, Enter for default): {COLOR_RESET}",
default_path=default_download_dir,
create_if_missing=True,
no_header=True
)
if download_dir_path is None:
self.logger.info("User cancelled at download directory prompt.")
return None
self.context['download_dir'] = download_dir_path
self.logger.debug(f"Download directory context set to: {self.context['download_dir']}")
if 'nexus_api_key' not in self.context or not self.context.get('nexus_api_key'):
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
authenticated, method, username = auth_service.get_auth_status()
if authenticated:
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}")
api_key, oauth_info = auth_service.get_auth_for_engine()
if api_key:
self.context['nexus_api_key'] = api_key
self.context['nexus_oauth_info'] = oauth_info
else:
print(f"\n{COLOR_WARNING}Your authentication has expired or is invalid.{COLOR_RESET}")
authenticated = False
if not authenticated:
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'):
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: You may see a security warning about a self-signed certificate.{COLOR_RESET}")
print(f"{COLOR_INFO}This is normal - click 'Advanced' and 'Proceed' to continue.{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, oauth_info = auth_service.get_auth_for_engine()
if api_key:
self.context['nexus_api_key'] = api_key
self.context['nexus_oauth_info'] = oauth_info
else:
print(f"{COLOR_ERROR}Failed to retrieve auth token after authorization.{COLOR_RESET}")
return None
else:
print(f"\n{COLOR_ERROR}OAuth authorization failed.{COLOR_RESET}")
return None
else:
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("Nexus authentication configured for engine.")
self._display_summary()
game_type = None
game_name = None
if self.context.get('modlist_source_type') == 'online_list':
game_name = self.context.get('modlist_game', '')
game_mapping = {
'skyrim special edition': 'skyrim',
'skyrim': 'skyrim',
'fallout 4': 'fallout4',
'fallout new vegas': 'falloutnv',
'oblivion': 'oblivion',
'starfield': 'starfield',
'oblivion remastered': 'oblivion_remastered'
}
game_type = game_mapping.get(game_name.lower())
if not game_type:
game_type = 'unknown'
elif self.context.get('modlist_source_type') == 'local_file':
wabbajack_path = self.context.get('modlist_value')
if wabbajack_path:
result = self.wabbajack_parser.parse_wabbajack_game_type(Path(wabbajack_path))
if result:
if isinstance(result, tuple):
game_type, raw_game_type = result
game_name = raw_game_type if game_type == 'unknown' else game_type
else:
game_type = result
game_name = game_type
if game_type and not self.wabbajack_parser.is_supported_game(game_type):
print("\n" + "" * 46)
print(" Game Support Notice\n")
print(f"You are about to install a modlist for: {game_name or 'Unknown'}\n")
print("Jackify does not provide post-install configuration for this game.")
print("You can still install and use the modlist, but you will need to manually set up Steam shortcuts and other steps after installation.\n")
print("Press [Enter] to continue, or [Ctrl+C] to cancel.")
print("" * 46 + "\n")
try:
input()
except KeyboardInterrupt:
print(f"{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}")
return None
if self.context.get('skip_confirmation'):
confirm = 'y'
else:
confirm = input(f"{COLOR_PROMPT}Proceed with installation using these settings? (y/N): {COLOR_RESET}").strip().lower()
if confirm != 'y':
self.logger.info("User cancelled at final confirmation.")
print(f"{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}")
return None
self.logger.info("Discovery phase complete.")
context_for_logging = self.context.copy()
if 'nexus_api_key' in context_for_logging and context_for_logging['nexus_api_key'] is not None:
context_for_logging['nexus_api_key'] = "[REDACTED]"
self.logger.info(f"Context: {context_for_logging}")
return self.context

View File

@@ -0,0 +1,67 @@
"""Game detection methods for ModlistInstallCLI (Mixin)."""
import logging
from pathlib import Path
from typing import Optional, Dict
logger = logging.getLogger(__name__)
class ModlistOperationsGameDetectionMixin:
"""Mixin providing game type detection methods."""
def detect_game_type(self, modlist_info: Optional[Dict] = None, wabbajack_file_path: Optional[Path] = None) -> Optional[str]:
"""
Detect the game type for a modlist installation.
Args:
modlist_info: Dictionary containing modlist information (for online modlists)
wabbajack_file_path: Path to .wabbajack file (for local files)
Returns:
Jackify game type string or None if detection fails
"""
if wabbajack_file_path:
self.logger.info(f"Detecting game type from .wabbajack file: {wabbajack_file_path}")
game_type = self.wabbajack_parser.parse_wabbajack_game_type(wabbajack_file_path)
if game_type:
self.logger.info(f"Detected game type from .wabbajack file: {game_type}")
return game_type
else:
self.logger.warning(f"Could not detect game type from .wabbajack file: {wabbajack_file_path}")
return None
elif modlist_info and 'game' in modlist_info:
game_name = modlist_info['game'].lower()
self.logger.info(f"Detecting game type from modlist info: {game_name}")
game_mapping = {
'skyrim special edition': 'skyrim',
'skyrim': 'skyrim',
'fallout 4': 'fallout4',
'fallout new vegas': 'falloutnv',
'oblivion': 'oblivion',
'starfield': 'starfield',
'oblivion remastered': 'oblivion_remastered'
}
game_type = game_mapping.get(game_name)
if game_type:
self.logger.info(f"Mapped game name '{game_name}' to game type: {game_type}")
return game_type
else:
self.logger.warning(f"Unknown game name in modlist info: {game_name}")
return None
else:
self.logger.warning("No modlist info or .wabbajack file path provided for game detection")
return None
def check_game_support(self, game_type: str) -> bool:
"""
Check if a game type is supported by Jackify's post-install configuration.
Args:
game_type: Jackify game type string
Returns:
True if the game is supported, False otherwise
"""
return self.wabbajack_parser.is_supported_game(game_type)

View File

@@ -0,0 +1,99 @@
"""Nexus and engine methods for ModlistInstallCLI (Mixin)."""
import logging
import os
import re
import subprocess
from pathlib import Path
from typing import Optional
from ..handlers.ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_RESET
logger = logging.getLogger(__name__)
class ModlistOperationsNexusMixin:
"""Mixin providing Nexus API and engine methods."""
def _get_nexus_api_key(self) -> Optional[str]:
return self.context.get('nexus_api_key')
def get_all_modlists_from_engine(self, game_type=None):
"""
Call the Jackify engine with 'list-modlists' and return a list of modlist dicts.
Each dict should have at least 'id', 'game', 'download_size', 'install_size', 'total_size', and status flags.
Args:
game_type (str, optional): Filter by game type (e.g., "Skyrim", "Fallout New Vegas")
"""
from .modlist_operations import get_jackify_engine_path
engine_executable = get_jackify_engine_path()
engine_dir = os.path.dirname(engine_executable)
if not os.path.exists(engine_executable):
print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_RESET}")
print(f"{COLOR_INFO}Expected: {engine_executable}{COLOR_RESET}")
return []
env = os.environ.copy()
env["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1"
command = [engine_executable, 'list-modlists', '--show-all-sizes', '--show-machine-url']
if game_type:
command.extend(['--game', game_type])
try:
result = subprocess.run(
command,
capture_output=True, text=True, check=True,
env=env, cwd=engine_dir
)
lines = result.stdout.splitlines()
modlists = []
for line in lines:
line = line.strip()
if not line or line.startswith('Loading') or line.startswith('Loaded'):
continue
status_down = '[DOWN]' in line
status_nsfw = '[NSFW]' in line
clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip()
parts = clean_line.rsplit(' - ', 3)
if len(parts) != 4:
continue
modlist_name = parts[0].strip()
game_name = parts[1].strip()
sizes_str = parts[2].strip()
machine_url = parts[3].strip()
size_parts = sizes_str.split('|')
if len(size_parts) != 3:
continue
download_size = size_parts[0].strip()
install_size = size_parts[1].strip()
total_size = size_parts[2].strip()
if not modlist_name or not game_name or not machine_url:
continue
modlists.append({
'id': modlist_name,
'name': modlist_name,
'game': game_name,
'download_size': download_size,
'install_size': install_size,
'total_size': total_size,
'machine_url': machine_url,
'status_down': status_down,
'status_nsfw': status_nsfw
})
return modlists
except subprocess.CalledProcessError as e:
self.logger.error(f"list-modlists failed. Code: {e.returncode}")
if e.stdout:
self.logger.error(f"Engine stdout:\n{e.stdout}")
if e.stderr:
self.logger.error(f"Engine stderr:\n{e.stderr}")
print(f"{COLOR_ERROR}Failed to fetch modlist list. Engine error (Code: {e.returncode}).{COLOR_ERROR}")
return []
except Exception as e:
self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True)
print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_ERROR}")
return []

View File

@@ -5,17 +5,10 @@ Reusable tab completion functions for Jackify CLI, including bash-like path comp
import os
import readline
import logging # Added for debugging
import logging
# Get a logger for this module
completer_logger = logging.getLogger(__name__) # Logger will be named src.modules.completers
# Set level to DEBUG for this logger to ensure all debug messages are generated.
# These messages will be handled by handlers configured in the main application (e.g., via LoggingHandler).
completer_logger = logging.getLogger(__name__)
completer_logger.setLevel(logging.INFO)
# Ensure messages DO NOT propagate to the root logger's console handler by default.
# A dedicated file handler will be added in jackify-cli.py.
completer_logger.propagate = False
# IMPORTANT: Do NOT include '/' in the completer delimiters!
@@ -68,7 +61,6 @@ def path_completer(text, state):
final_match_strings_for_readline = []
text_dir_part = os.path.dirname(text)
# If text is a directory with trailing slash, use it as the base for completions
if os.path.isdir(text) and text.endswith(os.sep):
base_path = text
elif os.path.isdir(text):

View File

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

View File

@@ -0,0 +1,108 @@
"""
Config handler directory paths: install/download parent and modlist base dirs.
"""
import os
import logging
logger = logging.getLogger(__name__)
class ConfigDirectoriesMixin:
"""Mixin providing directory path getters/setters for ConfigHandler."""
def set_default_install_parent_dir(self, path):
"""Save the parent directory for modlist installations."""
try:
if path and os.path.exists(path):
self.settings["default_install_parent_dir"] = path
logger.debug("Default install parent directory saved: %s", path)
return self.save_config()
logger.warning("Invalid or non-existent path for install parent directory: %s", path)
return False
except Exception as e:
logger.error("Error saving install parent directory: %s", e)
return False
def get_default_install_parent_dir(self):
"""Retrieve the saved parent directory for modlist installations."""
try:
path = self.settings.get("default_install_parent_dir")
if path and os.path.exists(path):
logger.debug("Retrieved default install parent directory: %s", path)
return path
logger.debug("No valid default install parent directory found")
return None
except Exception as e:
logger.error("Error retrieving install parent directory: %s", e)
return None
def set_default_download_parent_dir(self, path):
"""Save the parent directory for downloads."""
try:
if path and os.path.exists(path):
self.settings["default_download_parent_dir"] = path
logger.debug("Default download parent directory saved: %s", path)
return self.save_config()
logger.warning("Invalid or non-existent path for download parent directory: %s", path)
return False
except Exception as e:
logger.error("Error saving download parent directory: %s", e)
return False
def get_default_download_parent_dir(self):
"""Retrieve the saved parent directory for downloads."""
try:
path = self.settings.get("default_download_parent_dir")
if path and os.path.exists(path):
logger.debug("Retrieved default download parent directory: %s", path)
return path
logger.debug("No valid default download parent directory found")
return None
except Exception as e:
logger.error("Error retrieving download parent directory: %s", e)
return None
def has_saved_install_parent_dir(self):
"""Check if a default install parent directory is saved and valid."""
path = self.settings.get("default_install_parent_dir")
return path is not None and os.path.exists(path)
def has_saved_download_parent_dir(self):
"""Check if a default download parent directory is saved and valid."""
path = self.settings.get("default_download_parent_dir")
return path is not None and os.path.exists(path)
def get_modlist_install_base_dir(self):
"""Get the configurable base directory for modlist installations."""
return self.settings.get("modlist_install_base_dir", os.path.expanduser("~/Games"))
def set_modlist_install_base_dir(self, path):
"""Set the configurable base directory for modlist installations."""
try:
if path:
self.settings["modlist_install_base_dir"] = path
logger.debug("Modlist install base directory saved: %s", path)
return self.save_config()
logger.warning("Invalid path for modlist install base directory")
return False
except Exception as e:
logger.error("Error saving modlist install base directory: %s", e)
return False
def get_modlist_downloads_base_dir(self):
"""Get the configurable base directory for modlist downloads."""
return self.settings.get("modlist_downloads_base_dir", os.path.expanduser("~/Games/Modlist_Downloads"))
def set_modlist_downloads_base_dir(self, path):
"""Set the configurable base directory for modlist downloads."""
try:
if path:
self.settings["modlist_downloads_base_dir"] = path
logger.debug("Modlist downloads base directory saved: %s", path)
return self.save_config()
logger.warning("Invalid path for modlist downloads base directory")
return False
except Exception as e:
logger.error("Error saving modlist downloads base directory: %s", e)
return False

View File

@@ -0,0 +1,137 @@
"""
Config handler API key encryption and storage.
"""
import os
import base64
import hashlib
import logging
from typing import Optional
logger = logging.getLogger(__name__)
class ConfigEncryptionMixin:
"""Mixin providing encryption and API key storage for ConfigHandler."""
def _get_encryption_key(self) -> bytes:
"""Generate Fernet-compatible encryption key for API key storage."""
import socket
import getpass
try:
hostname = socket.gethostname()
username = getpass.getuser()
machine_id = None
try:
with open('/etc/machine-id', 'r') as f:
machine_id = f.read().strip()
except Exception:
try:
with open('/var/lib/dbus/machine-id', 'r') as f:
machine_id = f.read().strip()
except Exception:
pass
key_material = f"{hostname}:{username}:{machine_id}:jackify" if machine_id else f"{hostname}:{username}:jackify"
except Exception as e:
logger.warning("Failed to get machine info for encryption: %s", e)
key_material = "jackify:default: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."""
try:
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
key = base64.urlsafe_b64decode(self._get_encryption_key())
nonce = get_random_bytes(12)
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
ciphertext, tag = cipher.encrypt_and_digest(api_key.encode('utf-8'))
combined = nonce + ciphertext + tag
return base64.b64encode(combined).decode('utf-8')
except ImportError:
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("Error encrypting API key: %s", e)
return ""
def _decrypt_api_key(self, encrypted_key: str) -> Optional[str]:
"""Decrypt API key using AES-GCM."""
try:
from Crypto.Cipher import AES
if not hasattr(AES, 'MODE_GCM'):
try:
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
except Exception:
return None
key = base64.urlsafe_b64decode(self._get_encryption_key())
combined = base64.b64decode(encrypted_key.encode('utf-8'))
nonce = combined[:12]
tag = combined[-16:]
ciphertext = combined[12:-16]
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
plaintext = cipher.decrypt_and_verify(ciphertext, tag)
return plaintext.decode('utf-8')
except ImportError:
try:
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
except Exception:
return None
except (AttributeError, Exception):
try:
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
except Exception as e:
logger.error("Error decrypting API key: %s", e)
return None
def save_api_key(self, api_key):
"""Save Nexus API key with encryption."""
try:
if api_key:
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:
self.settings["nexus_api_key"] = None
logger.debug("API key cleared")
result = self.save_config()
if result:
try:
os.chmod(self.config_file, 0o600)
except Exception as e:
logger.warning("Could not set restrictive permissions on config: %s", e)
return result
except Exception as e:
logger.error("Error saving API key: %s", e)
return False
def get_api_key(self):
"""Retrieve and decrypt the saved Nexus API key. Always reads fresh from disk."""
try:
config = self._read_config_from_disk()
encrypted_key = config.get("nexus_api_key")
if encrypted_key:
return self._decrypt_api_key(encrypted_key)
return None
except Exception as e:
logger.error("Error retrieving API key: %s", e)
return None
def has_saved_api_key(self):
"""Check if an API key is saved in configuration. Always reads fresh from disk."""
config = self._read_config_from_disk()
return config.get("nexus_api_key") is not None
def clear_api_key(self):
"""Clear the saved API key from configuration."""
try:
self.settings["nexus_api_key"] = None
logger.debug("API key cleared from configuration")
return self.save_config()
except Exception as e:
logger.error("Error clearing API key: %s", e)
return False

View File

@@ -0,0 +1,76 @@
"""
Config handler Proton path and version getters and auto-detect.
"""
import logging
logger = logging.getLogger(__name__)
class ConfigProtonMixin:
"""Mixin providing Proton path/version and auto-detect for ConfigHandler."""
def get_proton_path(self):
"""Retrieve the saved Install Proton path. Always reads fresh from disk."""
try:
config = self._read_config_from_disk()
proton_path = config.get("proton_path")
if not proton_path:
logger.debug("proton_path not set in config - will use auto-detection")
return None
logger.debug("Retrieved fresh install proton_path from config: %s", proton_path)
return proton_path
except Exception as e:
logger.error("Error retrieving install proton_path: %s", e)
return None
def get_game_proton_path(self):
"""Retrieve the saved Game Proton path. Falls back to install Proton. Always reads fresh from disk."""
try:
config = self._read_config_from_disk()
game_proton_path = config.get("game_proton_path")
if not game_proton_path or game_proton_path == "same_as_install":
game_proton_path = config.get("proton_path")
if not game_proton_path:
logger.debug("game_proton_path not set in config - will use auto-detection")
return None
logger.debug("Retrieved fresh game proton_path from config: %s", game_proton_path)
return game_proton_path
except Exception as e:
logger.error("Error retrieving game proton_path: %s", e)
return "auto"
def get_proton_version(self):
"""Retrieve the saved Proton version. Always reads fresh from disk."""
try:
config = self._read_config_from_disk()
proton_version = config.get("proton_version", "auto")
logger.debug("Retrieved fresh proton_version from config: %s", proton_version)
return proton_version
except Exception as e:
logger.error("Error retrieving proton_version: %s", e)
return "auto"
def _auto_detect_proton(self):
"""Auto-detect and set best Proton version (GE-Proton and Valve Proton)."""
try:
from .wine_utils import WineUtils
best_proton = WineUtils.select_best_proton()
if best_proton:
self.settings["proton_path"] = str(best_proton['path'])
self.settings["proton_version"] = best_proton['name']
proton_type = best_proton.get('type', 'Unknown')
logger.info("Auto-detected Proton: %s (%s)", best_proton['name'], proton_type)
self.save_config()
else:
self.settings["proton_path"] = None
self.settings["proton_version"] = None
logger.warning("No compatible Proton versions found - proton_path set to null in config.json")
logger.info("Jackify will auto-detect Proton on each run until a valid version is found")
self.save_config()
except Exception as e:
logger.error("Failed to auto-detect Proton: %s", e)
self.settings["proton_path"] = None
self.settings["proton_version"] = None
logger.warning("proton_path set to null in config.json due to auto-detection failure")
self.save_config()

View File

@@ -73,7 +73,7 @@ def diagnose_stalled_engine(pid: int, duration: int = 60) -> Dict[str, Any]:
samples.append(sample)
# Real-time status
status_icon = "🟢" if sample['cpu_percent'] > 10 else "🟡" if sample['cpu_percent'] > 2 else "🔴"
status_icon = "[OK]" if sample['cpu_percent'] > 10 else "[WARN]" if sample['cpu_percent'] > 2 else "[CRIT]"
print(f"{status_icon} CPU: {sample['cpu_percent']:5.1f}% | Memory: {sample['memory_mb']:6.1f}MB | "
f"Threads: {sample['thread_count']:2d} | Status: {sample['status']}")

View File

@@ -179,7 +179,7 @@ class EnginePerformanceMonitor:
if metrics.parent_cpu_percent is not None:
parent_info = f", Python wrapper: {metrics.parent_cpu_percent:.1f}% CPU"
self.logger.warning(f"🚨 ENGINE STALL DETECTED: jackify-engine CPU at {metrics.cpu_percent:.1f}% "
self.logger.warning(f"ENGINE STALL DETECTED: jackify-engine CPU at {metrics.cpu_percent:.1f}% "
f"for {self.stall_duration}s+ (Memory: {metrics.memory_mb:.1f}MB, "
f"Threads: {metrics.thread_count}, FDs: {metrics.fd_count}{parent_info})")

View File

@@ -11,19 +11,20 @@ from typing import Optional, List, Dict, Tuple
from datetime import datetime
import re
import time
import subprocess # Needed for running sudo commands
import pwd # To get user name
import grp # To get group name
import requests # Import requests
import vdf # Import VDF library at the top level
import subprocess
import pwd
import grp
from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET
# Initialize logger for the module
from .filesystem_handler_download import FilesystemDownloadMixin
from .filesystem_handler_ownership import FilesystemOwnershipMixin
from .filesystem_handler_steam import FilesystemSteamMixin
logger = logging.getLogger(__name__)
class FileSystemHandler:
class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, FilesystemSteamMixin):
def __init__(self):
# Keep instance logger if needed, but static methods use module logger
self.logger = logging.getLogger(__name__)
@staticmethod
@@ -36,7 +37,7 @@ class FileSystemHandler:
return Path(path)
except Exception as e:
logger.error(f"Failed to normalize path {path}: {e}")
return Path(path) # Return original path as Path object on error
return Path(path)
@staticmethod
def validate_path(path: Path) -> bool:
@@ -50,7 +51,6 @@ class FileSystemHandler:
logger.warning(f"Validation failed: No read access - {path}")
return False
# Check write access (important for many operations)
# For directories, check write on parent; for files, check write on file itself
if path.is_dir():
if not os.access(path, os.W_OK):
logger.warning(f"Validation failed: No write access to directory - {path}")
@@ -60,7 +60,7 @@ class FileSystemHandler:
if not os.access(path.parent, os.W_OK):
logger.warning(f"Validation failed: No write access to parent dir of file - {path.parent}")
return False
return True # Passed existence and access checks
return True
except Exception as e:
logger.error(f"Failed to validate path {path}: {e}")
return False
@@ -192,16 +192,16 @@ class FileSystemHandler:
if recursive and path.is_dir():
for root, dirs, files in os.walk(path):
try:
os.chmod(root, 0o755) # Dirs typically 755
os.chmod(root, 0o755)
except Exception as dir_e:
logger.warning(f"Failed to chmod dir {root}: {dir_e}")
for file in files:
try:
os.chmod(os.path.join(root, file), 0o644) # Files typically 644
os.chmod(os.path.join(root, file), 0o644)
except Exception as file_e:
logger.warning(f"Failed to chmod file {os.path.join(root, file)}: {file_e}")
elif path.is_file():
os.chmod(path, 0o644 if permissions == 0o755 else permissions) # Default file perms 644
os.chmod(path, 0o644 if permissions == 0o755 else permissions)
elif path.is_dir():
os.chmod(path, permissions) # Set specific perm for top-level dir if not recursive
logger.debug(f"Set permissions for {path} (recursive={recursive})")
@@ -239,12 +239,6 @@ class FileSystemHandler:
logger.debug(f"Path {path} matches SD card pattern: {pattern}")
return True
# Less reliable: Check mount point info (can be slow/complex)
# try:
# # ... (logic using /proc/mounts or df command) ...
# except Exception as mount_e:
# logger.warning(f"Could not reliably check mount point for {path}: {mount_e}")
logger.debug(f"Path {path} does not appear to be on a standard SD card mount.")
return False
@@ -306,7 +300,7 @@ class FileSystemHandler:
FileSystemHandler.ensure_directory(destination.parent)
shutil.move(str(source), str(destination)) # shutil.move needs strings
shutil.move(str(source), str(destination))
logger.info(f"Moved directory {source} to {destination}")
return True
except Exception as e:
@@ -321,8 +315,6 @@ class FileSystemHandler:
logger.error(f"Copy failed: Source is not a directory - {source}")
return False
# shutil.copytree needs destination to NOT exist unless dirs_exist_ok=True (Py 3.8+)
# Ensure parent exists
FileSystemHandler.ensure_directory(destination.parent)
shutil.copytree(source, destination, dirs_exist_ok=dirs_exist_ok)
@@ -392,100 +384,6 @@ class FileSystemHandler:
logger.error(f"Failed to add backupPath entry to {modlist_ini}: {e}")
return False # Backup succeeded, but adding entry failed
@staticmethod
def blank_downloads_dir(modlist_ini: Path) -> bool:
"""Blanks the download_directory line in ModOrganizer.ini."""
logger.info(f"Blanking download_directory in {modlist_ini}...")
try:
content = modlist_ini.read_text().splitlines()
new_content = []
found = False
for line in content:
if line.strip().startswith("download_directory="):
new_content.append("download_directory=")
found = True
else:
new_content.append(line)
if found:
modlist_ini.write_text("\n".join(new_content) + "\n")
logger.debug("download_directory line blanked.")
else:
logger.warning("download_directory line not found.")
# Consider if we should add it blank?
return True
except Exception as e:
logger.error(f"Failed to blank download_directory in {modlist_ini}: {e}")
return False
@staticmethod
def copy_file(src: Path, dst: Path, overwrite: bool = False) -> bool:
"""Copy a single file."""
try:
if not src.is_file():
logger.error(f"Copy failed: Source is not a file - {src}")
return False
if dst.exists() and not overwrite:
logger.warning(f"Copy skipped: Destination exists and overwrite=False - {dst}")
return False # Or True, depending on desired behavior for skip
FileSystemHandler.ensure_directory(dst.parent)
shutil.copy2(src, dst)
logger.debug(f"Copied file {src} to {dst}")
return True
except Exception as e:
logger.error(f"Failed to copy file {src} to {dst}: {e}")
return False
@staticmethod
def move_file(src: Path, dst: Path, overwrite: bool = False) -> bool:
"""Move a single file."""
try:
if not src.is_file():
logger.error(f"Move failed: Source is not a file - {src}")
return False
if dst.exists() and not overwrite:
logger.warning(f"Move skipped: Destination exists and overwrite=False - {dst}")
return False
FileSystemHandler.ensure_directory(dst.parent)
shutil.move(str(src), str(dst)) # shutil.move needs strings
# Create backup with timestamp
timestamp = os.path.getmtime(modlist_ini)
backup_path = modlist_ini.with_suffix(f'.{timestamp:.0f}.bak')
# Copy file to backup
shutil.copy2(modlist_ini, backup_path)
# Copy game path to backup path
with open(modlist_ini, 'r') as f:
lines = f.readlines()
game_path_line = None
for line in lines:
if line.startswith('gamePath'):
game_path_line = line
break
if game_path_line:
# Create backup path entry
backup_path_line = game_path_line.replace('gamePath', 'backupPath')
# Append to file if not already present
with open(modlist_ini, 'a') as f:
f.write(backup_path_line)
self.logger.debug(f"Backed up ModOrganizer.ini and created backupPath entry")
return True
else:
self.logger.error("No gamePath found in ModOrganizer.ini")
return False
except Exception as e:
self.logger.error(f"Error backing up ModOrganizer.ini: {e}")
return False
def blank_downloads_dir(self, modlist_ini: Path) -> bool:
"""
Blank or reset the MO2 Downloads Directory
@@ -664,7 +562,7 @@ class FileSystemHandler:
self.logger.debug(f"Created game-specific directory: {dir_path}")
# CRITICAL: Create game-specific Documents directories in Wine prefix
# This is required for USVFS to virtualize profile INI files on first launch
# Required for USVFS to virtualize profile INIs on first launch
if game_name in game_docs_dirs:
docs_dir_name = game_docs_dirs[game_name]
@@ -701,267 +599,3 @@ class FileSystemHandler:
except Exception as e:
self.logger.error(f"Error creating required directories: {e}")
return False
@staticmethod
def all_owned_by_user(path: Path) -> bool:
"""
Returns True if all files and directories under 'path' are owned by the current user.
"""
uid = os.getuid()
gid = os.getgid()
for root, dirs, files in os.walk(path):
for name in dirs + files:
full_path = os.path.join(root, name)
try:
stat = os.stat(full_path)
if stat.st_uid != uid or stat.st_gid != gid:
return False
except Exception:
return False
return True
@staticmethod
def verify_ownership_and_permissions(path: Path) -> tuple[bool, str]:
"""
Verify and fix ownership/permissions for modlist directory.
Returns (success, error_message).
Logic:
- If files NOT owned by user: Can't fix without sudo, return error with instructions
- If files owned by user: Try to fix permissions ourselves with chmod
"""
if not path.exists():
logger.error(f"Path does not exist: {path}")
return False, f"Path does not exist: {path}"
# Check if all files/dirs are owned by the user
if not FileSystemHandler.all_owned_by_user(path):
# Files not owned by us - need sudo to fix
try:
user_name = pwd.getpwuid(os.geteuid()).pw_name
group_name = grp.getgrgid(os.geteuid()).gr_name
except KeyError:
logger.error("Could not determine current user or group name.")
return False, "Could not determine current user or group name."
logger.error(f"Ownership issue detected: Some files in {path} are not owned by {user_name}")
error_msg = (
f"\nOwnership Issue Detected\n"
f"Some files in the modlist directory are not owned by your user account.\n"
f"This can happen if the modlist was copied from another location or installed by a different user.\n\n"
f"To fix this, open a terminal and run:\n\n"
f" sudo chown -R {user_name}:{group_name} \"{path}\"\n"
f" sudo chmod -R 755 \"{path}\"\n\n"
f"After running these commands, retry the configuration process."
)
return False, error_msg
# Files are owned by us - try to fix permissions ourselves
logger.info(f"Files in {path} are owned by current user, verifying permissions...")
try:
result = subprocess.run(
['chmod', '-R', '755', str(path)],
capture_output=True,
text=True,
check=False
)
if result.returncode == 0:
logger.info(f"Permissions set successfully for {path}")
return True, ""
else:
logger.warning(f"chmod returned non-zero but we'll continue: {result.stderr}")
# Non-critical if chmod fails on our own files, might be read-only filesystem or similar
return True, ""
except Exception as e:
logger.warning(f"Error running chmod: {e}, continuing anyway")
# Non-critical error, we own the files so proceed
return True, ""
@staticmethod
def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool:
"""
DEPRECATED: Use verify_ownership_and_permissions() instead.
This method is kept for backwards compatibility but no longer executes sudo.
"""
logger.warning("set_ownership_and_permissions_sudo() is deprecated - use verify_ownership_and_permissions()")
success, error_msg = FileSystemHandler.verify_ownership_and_permissions(path)
if not success:
logger.error(error_msg)
print(error_msg)
return success
def download_file(self, url: str, destination_path: Path, overwrite: bool = False, quiet: bool = False) -> bool:
"""Downloads a file from a URL to a destination path."""
self.logger.info(f"Downloading {url} to {destination_path}...")
if not overwrite and destination_path.exists():
self.logger.info(f"File already exists, skipping download: {destination_path}")
# Only print if not quiet
if not quiet:
print(f"File {destination_path.name} already exists, skipping download.")
return True # Consider existing file as success
try:
# Ensure destination directory exists
destination_path.parent.mkdir(parents=True, exist_ok=True)
# Perform the download with streaming
with requests.get(url, stream=True, timeout=300, verify=True) as r:
r.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
with open(destination_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
self.logger.info("Download complete.")
# Only print if not quiet
if not quiet:
print("Download complete.")
return True
except requests.exceptions.RequestException as e:
self.logger.error(f"Download failed: {e}")
print(f"Error: Download failed for {url}. Check network connection and URL.")
# Clean up potentially incomplete file
if destination_path.exists():
try: destination_path.unlink()
except OSError: pass
return False
except Exception as e:
self.logger.error(f"Error during download or file writing: {e}", exc_info=True)
print("Error: An unexpected error occurred during download.")
# Clean up potentially incomplete file
if destination_path.exists():
try: destination_path.unlink()
except OSError: pass
return False
@staticmethod
def find_steam_library() -> Optional[Path]:
"""
Find the Steam library containing game installations, prioritizing vdf.
Returns:
Optional[Path]: Path object to the Steam library's steamapps/common dir, or None if not found
"""
logger.info("Detecting Steam library location...")
# Try finding libraryfolders.vdf in common Steam paths
possible_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
]
libraryfolders_vdf_path: Optional[Path] = None
for path_obj in possible_vdf_paths:
# Explicitly ensure path_obj is Path before checking is_file
current_path = Path(path_obj)
if current_path.is_file():
libraryfolders_vdf_path = current_path # Assign the confirmed Path object
logger.debug(f"Found libraryfolders.vdf at: {libraryfolders_vdf_path}")
break
# Check AFTER loop - libraryfolders_vdf_path is now definitely Path or None
if not libraryfolders_vdf_path:
logger.warning("libraryfolders.vdf not found...")
# Proceed to default check below if vdf not found
else:
# Parse the VDF file to extract library paths
try:
# Try importing vdf here if not done globally
with open(libraryfolders_vdf_path, 'r') as f:
data = vdf.load(f)
# Look for library folders (indices are strings '0', '1', etc.)
libraries = data.get('libraryfolders', {})
for key in libraries:
if isinstance(libraries[key], dict) and 'path' in libraries[key]:
lib_path_str = libraries[key]['path']
if lib_path_str:
# Check if this library path is valid
potential_lib_path = Path(lib_path_str) / "steamapps/common"
if potential_lib_path.is_dir():
logger.info(f"Using Steam library path from vdf: {potential_lib_path}")
return potential_lib_path # Return first valid Path object found
logger.warning("No valid library paths found within libraryfolders.vdf.")
# Proceed to default check below if vdf parsing fails to find a valid path
except ImportError:
logger.error("Python 'vdf' library not found. Cannot parse libraryfolders.vdf.")
# Proceed to default check below
except Exception as e:
logger.error(f"Error parsing libraryfolders.vdf: {e}")
# Proceed to default check below
# Fallback: Check default location if VDF parsing didn't yield a result
default_path = Path.home() / ".steam/steam/steamapps/common"
if default_path.is_dir():
logger.warning(f"Using default Steam library path: {default_path}")
return default_path
logger.error("No valid Steam library found via vdf or at default location.")
return None
@staticmethod
def find_compat_data(appid: str) -> Optional[Path]:
"""Find the compatdata directory for a given AppID."""
if not appid or not appid.isdigit():
logger.error(f"Invalid AppID provided for compatdata search: {appid}")
return None
logger.debug(f"Searching for compatdata directory for AppID: {appid}")
# Standard Steam locations
possible_bases = [
Path.home() / ".steam/steam/steamapps/compatdata",
Path.home() / ".local/share/Steam/steamapps/compatdata",
]
# Try to get library path from vdf to check there too
# Use type hint for clarity
steam_lib_common_path: Optional[Path] = FileSystemHandler.find_steam_library()
if steam_lib_common_path:
# find_steam_library returns steamapps/common, go up two levels for library root
library_root = steam_lib_common_path.parent.parent
vdf_compat_path = library_root / "steamapps/compatdata"
if vdf_compat_path.is_dir() and vdf_compat_path not in possible_bases:
possible_bases.insert(0, vdf_compat_path) # Prioritize library path from vdf
for base_path in possible_bases:
if not base_path.is_dir():
logger.debug(f"Compatdata base path does not exist or is not a directory: {base_path}")
continue
potential_path = base_path / appid
if potential_path.is_dir():
logger.info(f"Found compatdata directory: {potential_path}")
return potential_path # Return Path object
else:
logger.debug(f"Compatdata for {appid} not found in {base_path}")
logger.warning(f"Compatdata directory for AppID {appid} not found in standard or detected library locations.")
return None
@staticmethod
def find_steam_config_vdf() -> Optional[Path]:
"""Finds the active Steam config.vdf file."""
logger.debug("Searching for Steam config.vdf...")
possible_steam_paths = [
Path.home() / ".steam/steam",
Path.home() / ".local/share/Steam",
Path.home() / ".steam/root"
]
for steam_path in possible_steam_paths:
potential_path = steam_path / "config/config.vdf"
if potential_path.is_file():
logger.info(f"Found config.vdf at: {potential_path}")
return potential_path # Return Path object
logger.warning("Could not locate Steam's config.vdf file in standard locations.")
return None
# ... (rest of the class) ...

View File

@@ -0,0 +1,55 @@
"""
Filesystem download operations: download_file.
"""
import logging
from pathlib import Path
import requests
logger = logging.getLogger(__name__)
class FilesystemDownloadMixin:
"""Mixin providing download_file for FileSystemHandler."""
def download_file(self, url: str, destination_path: Path, overwrite: bool = False, quiet: bool = False) -> bool:
"""Download a file from a URL to a destination path."""
self.logger.info("Downloading %s to %s...", url, destination_path)
if not overwrite and destination_path.exists():
self.logger.info("File already exists, skipping download: %s", destination_path)
if not quiet:
self.logger.info("File %s already exists, skipping download.", destination_path.name)
return True
try:
destination_path.parent.mkdir(parents=True, exist_ok=True)
with requests.get(url, stream=True, timeout=300, verify=True) as r:
r.raise_for_status()
with open(destination_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
self.logger.info("Download complete.")
if not quiet:
self.logger.info("Download complete.")
return True
except requests.exceptions.RequestException as e:
self.logger.error("Download failed: %s", e)
self.logger.error("Download failed for %s. Check network connection and URL.", url)
if destination_path.exists():
try:
destination_path.unlink()
except OSError:
pass
return False
except Exception as e:
self.logger.error("Error during download or file writing: %s", e, exc_info=True)
self.logger.error("An unexpected error occurred during download.")
if destination_path.exists():
try:
destination_path.unlink()
except OSError:
pass
return False

View File

@@ -0,0 +1,89 @@
"""
Filesystem ownership and permissions: all_owned_by_user, verify_ownership_and_permissions, set_ownership_and_permissions_sudo.
"""
import os
import logging
import subprocess
import pwd
import grp
from pathlib import Path
logger = logging.getLogger(__name__)
class FilesystemOwnershipMixin:
"""Mixin providing ownership check and sudo-compatible fix for FileSystemHandler."""
@staticmethod
def all_owned_by_user(path: Path) -> bool:
"""Return True if all files and directories under path are owned by the current user."""
uid = os.getuid()
gid = os.getgid()
for root, dirs, files in os.walk(path):
for name in dirs + files:
full_path = os.path.join(root, name)
try:
stat = os.stat(full_path)
if stat.st_uid != uid or stat.st_gid != gid:
return False
except Exception:
return False
return True
@staticmethod
def verify_ownership_and_permissions(path: Path) -> tuple:
"""
Verify and fix ownership/permissions for modlist directory.
Returns (success, error_message).
"""
if not path.exists():
logger.error("Path does not exist: %s", path)
return False, f"Path does not exist: {path}"
if not FilesystemOwnershipMixin.all_owned_by_user(path):
try:
user_name = pwd.getpwuid(os.geteuid()).pw_name
group_name = grp.getgrgid(os.geteuid()).gr_name
except KeyError:
logger.error("Could not determine current user or group name.")
return False, "Could not determine current user or group name."
logger.error("Ownership issue detected: Some files in %s are not owned by %s", path, user_name)
error_msg = (
f"\nOwnership Issue Detected\n"
f"Some files in the modlist directory are not owned by your user account.\n"
f"This can happen if the modlist was copied from another location or installed by a different user.\n\n"
f"To fix this, open a terminal and run:\n\n"
f" sudo chown -R {user_name}:{group_name} \"{path}\"\n"
f" sudo chmod -R 755 \"{path}\"\n\n"
f"After running these commands, retry the configuration process."
)
return False, error_msg
logger.info("Files in %s are owned by current user, verifying permissions...", path)
try:
result = subprocess.run(
['chmod', '-R', '755', str(path)],
capture_output=True,
text=True,
check=False
)
if result.returncode == 0:
logger.info("Permissions set successfully for %s", path)
return True, ""
logger.warning("chmod returned non-zero but we'll continue: %s", result.stderr)
return True, ""
except Exception as e:
logger.warning("Error running chmod: %s, continuing anyway", e)
return True, ""
@staticmethod
def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool:
"""Deprecated: use verify_ownership_and_permissions() instead. Kept for backwards compatibility."""
logger.warning("set_ownership_and_permissions_sudo() is deprecated - use verify_ownership_and_permissions()")
success, error_msg = FilesystemOwnershipMixin.verify_ownership_and_permissions(path)
if not success:
logger.error("%s", error_msg)
return success

View File

@@ -0,0 +1,124 @@
"""
Steam path discovery for FileSystemHandler: find_steam_library, find_compat_data, find_steam_config_vdf.
"""
import logging
from pathlib import Path
from typing import Optional
import vdf
logger = logging.getLogger(__name__)
class FilesystemSteamMixin:
"""Mixin providing Steam library and compatdata path discovery for FileSystemHandler."""
@staticmethod
def find_steam_library() -> Optional[Path]:
"""
Find the Steam library containing game installations, prioritizing vdf.
Returns:
Optional[Path]: Path object to the Steam library's steamapps/common dir, or None if not found
"""
logger.info("Detecting Steam library location...")
possible_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"
]
libraryfolders_vdf_path: Optional[Path] = None
for path_obj in possible_vdf_paths:
current_path = Path(path_obj)
if current_path.is_file():
libraryfolders_vdf_path = current_path
logger.debug(f"Found libraryfolders.vdf at: {libraryfolders_vdf_path}")
break
if not libraryfolders_vdf_path:
logger.warning("libraryfolders.vdf not found...")
else:
try:
with open(libraryfolders_vdf_path, 'r') as f:
data = vdf.load(f)
libraries = data.get('libraryfolders', {})
for key in libraries:
if isinstance(libraries[key], dict) and 'path' in libraries[key]:
lib_path_str = libraries[key]['path']
if lib_path_str:
potential_lib_path = Path(lib_path_str) / "steamapps/common"
if potential_lib_path.is_dir():
logger.info(f"Using Steam library path from vdf: {potential_lib_path}")
return potential_lib_path
logger.warning("No valid library paths found within libraryfolders.vdf.")
except ImportError:
logger.error("Python 'vdf' library not found. Cannot parse libraryfolders.vdf.")
except Exception as e:
logger.error(f"Error parsing libraryfolders.vdf: {e}")
default_path = Path.home() / ".steam/steam/steamapps/common"
if default_path.is_dir():
logger.warning(f"Using default Steam library path: {default_path}")
return default_path
logger.error("No valid Steam library found via vdf or at default location.")
return None
@staticmethod
def find_compat_data(appid: str) -> Optional[Path]:
"""Find the compatdata directory for a given AppID."""
if not appid or not appid.isdigit():
logger.error(f"Invalid AppID provided for compatdata search: {appid}")
return None
logger.debug(f"Searching for compatdata directory for AppID: {appid}")
possible_bases = [
Path.home() / ".steam/steam/steamapps/compatdata",
Path.home() / ".local/share/Steam/steamapps/compatdata",
]
steam_lib_common_path: Optional[Path] = FilesystemSteamMixin.find_steam_library()
if steam_lib_common_path:
library_root = steam_lib_common_path.parent.parent
vdf_compat_path = library_root / "steamapps/compatdata"
if vdf_compat_path.is_dir() and vdf_compat_path not in possible_bases:
possible_bases.insert(0, vdf_compat_path)
for base_path in possible_bases:
if not base_path.is_dir():
logger.debug(f"Compatdata base path does not exist or is not a directory: {base_path}")
continue
potential_path = base_path / appid
if potential_path.is_dir():
logger.info(f"Found compatdata directory: {potential_path}")
return potential_path
logger.debug(f"Compatdata for {appid} not found in {base_path}")
logger.warning(f"Compatdata directory for AppID {appid} not found in standard or detected library locations.")
return None
@staticmethod
def find_steam_config_vdf() -> Optional[Path]:
"""Finds the active Steam config.vdf file."""
logger.debug("Searching for Steam config.vdf...")
possible_steam_paths = [
Path.home() / ".steam/steam",
Path.home() / ".local/share/Steam",
Path.home() / ".steam/root"
]
for steam_path in possible_steam_paths:
potential_path = steam_path / "config/config.vdf"
if potential_path.is_file():
logger.info(f"Found config.vdf at: {potential_path}")
return potential_path
logger.warning("Could not locate Steam's config.vdf file in standard locations.")
return None

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,6 @@ import sys
import logging
import time
import subprocess # Add subprocess import
# from datetime import datetime # Not used currently
import argparse
import re
from typing import List, Dict, Optional
@@ -37,6 +36,11 @@ from .mo2_handler import MO2Handler
from jackify.shared.ui_utils import print_section_header
from .completers import path_completer
try:
import readline
except ImportError:
readline = None
# Define exports for this module
__all__ = [
'MenuHandler',
@@ -47,700 +51,11 @@ __all__ = [
# Initialize logger
logger = logging.getLogger(__name__)
# --- Input Handling with Readline Tab Completion ---
# Simple function for basic input
def basic_input_prompt(message, **kwargs):
return input(message)
# --- Readline for tab completion ---
READLINE_AVAILABLE = False
READLINE_HAS_PROMPT = False
READLINE_HAS_DISPLAY_HOOK = False
try:
import readline
READLINE_AVAILABLE = True
logging.debug("Readline imported for tab completion")
# Check for the specific features we want to use
if hasattr(readline, 'set_prompt'):
READLINE_HAS_PROMPT = True
logging.debug("Readline has set_prompt capability")
else:
logging.debug("Readline does not have set_prompt capability, will use fallback")
# Test readline tab completion functionality
try:
# Try to parse tab configuration to confirm readline is properly configured
readline.parse_and_bind('tab: complete')
logging.debug("Readline tab completion successfully configured")
except Exception as e:
logging.warning(f"Error configuring readline tab completion: {e}. Tab completion may be limited.")
# Set better readline behavior for displaying completions if available
if hasattr(readline, 'set_completion_display_matches_hook'):
READLINE_HAS_DISPLAY_HOOK = True
logging.debug("Readline has completion display hook capability")
def custom_display_completions(substitution, matches, longest_match_length):
"""Custom function to display completions with better formatting"""
# Print a newline to avoid overwriting the prompt
print()
# Get terminal width
try:
import shutil
term_width = shutil.get_terminal_size().columns
except (ImportError, AttributeError):
term_width = 80 # Default fallback
# Calculate how many completions to display per line
items_per_line = max(1, term_width // (longest_match_length + 2))
# Print completions in columns
for i, match in enumerate(matches):
print(f"{match:<{longest_match_length + 2}}", end='' if (i + 1) % items_per_line else '\n')
if len(matches) % items_per_line != 0:
print() # Ensure we end with a newline
# Re-display the prompt with the current input - use the safe approach
current_input = readline.get_line_buffer()
# Use the visual prompt string which may not be exactly what readline knows as the prompt
print(f"{COLOR_PROMPT}> {COLOR_RESET}{current_input}", end='', flush=True)
try:
# Set the custom display function
readline.set_completion_display_matches_hook(custom_display_completions)
logging.debug("Custom completion display hook successfully set")
except Exception as e:
logging.warning(f"Error setting completion display hook: {e}. Using default display behavior.")
READLINE_HAS_DISPLAY_HOOK = False
else:
logging.debug("Readline doesn't have completion display hook capability, using default")
except ImportError:
READLINE_AVAILABLE = False
READLINE_HAS_PROMPT = False
READLINE_HAS_DISPLAY_HOOK = False
logging.warning("readline not available. Tab completion for paths will be disabled.")
except Exception as e:
READLINE_AVAILABLE = False
READLINE_HAS_PROMPT = False
READLINE_HAS_DISPLAY_HOOK = False
logging.warning(f"Error initializing readline: {e}. Tab completion for paths will be disabled.")
# --- DEBUG PRINT ---
# --- END DEBUG PRINT ---
class ModlistMenuHandler:
"""
Handles modlist-specific menu operations
"""
def __init__(self, config_handler, test_mode=False):
"""Initialize the ModlistMenuHandler with configuration"""
self.config_handler = config_handler
self.test_mode = test_mode
self.exit_flag = False
self.logger = logging.getLogger(__name__)
# Initialize handlers
try:
# Initialize filesystem handler first, others may depend on it
self.filesystem_handler = FileSystemHandler()
# Initialize basic handlers
self.path_handler = PathHandler()
self.vdf_handler = VDFHandler()
# Determine Steam Deck status using centralized detection
from ..services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
self.steamdeck = platform_service.is_steamdeck
# Create the resolution handler
self.resolution_handler = ResolutionHandler()
# Initialize menu handler for consistent UI
self.menu_handler = MenuHandler()
# Initialize modlist handler
self.modlist_handler = ModlistHandler(
self.config_handler.settings,
steamdeck=self.steamdeck,
verbose=False,
filesystem_handler=self.filesystem_handler
)
self.shortcut_handler = self.modlist_handler.shortcut_handler
# Initialize the wabbajack installation handler
self.install_wabbajack_handler = None
except Exception as e:
self.logger.error(f"Error initializing ModlistMenuHandler: {e}")
# Initialize with defaults/empty to prevent errors
self.filesystem_handler = FileSystemHandler()
# Use centralized detection even in fallback
try:
from ..services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
self.steamdeck = platform_service.is_steamdeck
except:
self.steamdeck = False # Final fallback
self.modlist_handler = None
def show_modlist_menu(self):
while True:
os.system('cls' if os.name == 'nt' else 'clear')
# Banner display handled by frontend
print_section_header('Modlist Configuration')
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Configure a New modlist not yet in Steam")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure a modlist already in Steam")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}")
if choice == "1":
if not self._configure_new_modlist():
return False
elif choice == "2":
if not self._configure_existing_modlist():
return False
elif choice == "0":
logger.info("Returning to main menu from Modlist Configuration menu.")
return False
else:
logger.warning(f"Invalid menu selection: {choice}")
print("\nInvalid selection. Please try again.")
input("\nPress Enter to continue...")
def _display_manual_proton_steps(self, modlist_name):
"""Displays the detailed manual steps required for Proton setup."""
# Keep these as print for clear user instructions
print(f"\n{COLOR_PROMPT}--- Manual Proton Setup Required ---{COLOR_RESET}")
print("Please complete the following steps in Steam:")
print(f" 1. Locate the '{COLOR_INFO}{modlist_name}{COLOR_RESET}' entry in your Steam Library")
print(" 2. Right-click and select 'Properties'")
print(" 3. Switch to the 'Compatibility' tab")
print(" 4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'")
print(" 5. Select 'Proton - Experimental' from the dropdown menu")
print(" 6. Close the Properties window")
print(f" 7. Launch '{COLOR_INFO}{modlist_name}{COLOR_RESET}' from your Steam Library")
print(" 8. If Mod Organizer opens or produces any error message, that's normal")
print(" 9. No matter what,CLOSE Mod Organizer completely and return here")
print(f"{COLOR_PROMPT}------------------------------------{COLOR_RESET}")
def _get_mo2_path(self) -> Optional[str]:
"""
Get the path to ModOrganizer.exe from user input.
Returns the validated path or None if cancelled/invalid.
"""
self.logger.info("Prompting for ModOrganizer.exe path...")
print("\n" + "-" * 28) # Separator
print(f"{COLOR_PROMPT}Please provide the path to ModOrganizer.exe for your modlist.{COLOR_RESET}")
print(f"{COLOR_INFO}This is typically found in the modlist's installation directory.")
print(f"{COLOR_INFO}Example: ~/Games/MyModlist/ModOrganizer.exe")
print(f"{COLOR_INFO}You can also provide the path to the directory containing ModOrganizer.exe.")
# Use the menu_handler's get_existing_file_path for consistency if self.menu_handler is available
# Note: self.menu_handler here is an instance of MenuHandler, not ModlistMenuHandler
if hasattr(self, 'menu_handler') and self.menu_handler is not None:
# get_existing_file_path will use its own standard prompting style internally
# We pass no_header=False so it shows its full prompt.
# The prompt_message here becomes the main instruction for get_existing_file_path.
path_result = self.menu_handler.get_existing_file_path(
prompt_message=f"Path to ModOrganizer.exe or its directory",
extension_filter=".exe",
no_header=False # Let get_existing_file_path handle its full prompt including separator
)
if path_result is None: # User cancelled
self.logger.info("User cancelled ModOrganizer.exe path input via get_existing_file_path.")
return None
path_str = str(path_result)
if os.path.isdir(path_str):
potential_mo2_path = os.path.join(path_str, "ModOrganizer.exe")
if os.path.isfile(potential_mo2_path):
self.logger.info(f"Found ModOrganizer.exe in directory: {potential_mo2_path}")
return potential_mo2_path
else:
print(f"\n{COLOR_ERROR}Error: ModOrganizer.exe not found in directory: {path_str}{COLOR_RESET}")
# Allow to try again - this might need a loop or rely on get_existing_file_path loop
return self._get_mo2_path() # Recursive call to try again, simple loop better
elif os.path.isfile(path_str) and os.path.basename(path_str).lower() == "modorganizer.exe":
self.logger.info(f"ModOrganizer.exe path validated: {path_str}")
return path_str
else:
print(f"\n{COLOR_ERROR}Error: Path is not ModOrganizer.exe or a directory containing it.{COLOR_RESET}")
return self._get_mo2_path() # Recursive call
# Fallback to basic input if self.menu_handler is not available (should ideally not happen)
self.logger.warning("_get_mo2_path: self.menu_handler not available, using basic input as fallback.")
while True:
try:
# Basic input prompt if menu_handler isn't used
mo2_path_input = input(f"{COLOR_PROMPT}Enter path to ModOrganizer.exe (or 'q' to cancel): {COLOR_RESET}").strip()
if mo2_path_input.lower() == 'q':
self.logger.info("User cancelled ModOrganizer.exe path input (fallback).")
return None
expanded_path = os.path.expanduser(mo2_path_input)
normalized_path = os.path.normpath(expanded_path)
if os.path.isdir(normalized_path):
potential_mo2_path = os.path.join(normalized_path, "ModOrganizer.exe")
if os.path.isfile(potential_mo2_path):
self.logger.info(f"Found ModOrganizer.exe in directory (fallback): {potential_mo2_path}")
return potential_mo2_path
else:
print(f"{COLOR_ERROR}Error: ModOrganizer.exe not found in directory: {normalized_path}{COLOR_RESET}")
continue
if not normalized_path.lower().endswith('modorganizer.exe'):
print(f"{COLOR_ERROR}Error: Path must be ModOrganizer.exe or a directory containing it.{COLOR_RESET}")
continue
if not os.path.isfile(normalized_path):
print(f"{COLOR_ERROR}Error: File does not exist: {normalized_path}{COLOR_RESET}")
continue
self.logger.info(f"ModOrganizer.exe path validated (fallback): {normalized_path}")
return normalized_path
except KeyboardInterrupt:
print("\nOperation cancelled.")
self.logger.info("User cancelled ModOrganizer.exe path input via Ctrl+C (fallback).")
return None
except Exception as e:
self.logger.error(f"Error processing ModOrganizer.exe path (fallback): {e}")
print(f"{COLOR_ERROR}An error occurred: {e}{COLOR_RESET}")
return None
def _get_modlist_name(self) -> Optional[str]:
"""
Get the modlist name from user input.
Returns the validated name or None if cancelled.
"""
self.logger.info("Prompting for modlist name...")
print("\n" + "-" * 28) # Separator
print(f"{COLOR_PROMPT}Please provide a name for your modlist.{COLOR_RESET}")
print(f"{COLOR_INFO}(This will be the name used for the Steam shortcut.){COLOR_RESET}")
while True:
try:
modlist_name = input(f"{COLOR_PROMPT}Modlist Name (or 'q' to cancel): {COLOR_RESET}").strip()
if modlist_name.lower() == 'q':
self.logger.info("User cancelled modlist name input.")
return None
if not modlist_name:
print(f"{COLOR_ERROR}Error: Name cannot be empty.{COLOR_RESET}")
continue
if len(modlist_name) > 100:
print(f"{COLOR_ERROR}Error: Name is too long (max 100 characters).{COLOR_RESET}")
continue
invalid_chars = '< > : " / \\ | ? *' # String of invalid chars for message
if any(char in modlist_name for char in invalid_chars.replace(' ','')):
print(f"{COLOR_ERROR}Error: Name contains invalid characters (e.g., {invalid_chars}).{COLOR_RESET}")
continue
self.logger.info(f"Modlist name validated: {modlist_name}")
return modlist_name
except KeyboardInterrupt:
print("\nOperation cancelled.")
self.logger.info("User cancelled modlist name input via Ctrl+C.")
return None
except Exception as e:
self.logger.error(f"Error processing modlist name: {e}")
print(f"{COLOR_ERROR}An error occurred: {e}{COLOR_RESET}")
return None
def _configure_new_modlist(self, default_modlist_dir=None, default_modlist_name=None):
"""Handle configuration of a new modlist. Returns True to continue menu, False to exit."""
# --- Get ModOrganizer.exe Path ---
if default_modlist_dir:
# Try to infer ModOrganizer.exe path
mo2_path = os.path.join(default_modlist_dir, "ModOrganizer.exe")
if not os.path.isfile(mo2_path):
print(f"{COLOR_ERROR}Could not find ModOrganizer.exe in {default_modlist_dir}{COLOR_RESET}")
mo2_path = self._get_mo2_path()
else:
mo2_path = self._get_mo2_path()
if not mo2_path:
return True
# --- Get Modlist Name ---
if default_modlist_name:
modlist_name = default_modlist_name
else:
modlist_name = self._get_modlist_name()
if not modlist_name:
return True
# Add a blank line for padding
print("")
try:
# --- Ensure SteamIcons directory is normalized before icon selection ---
mo2_dir = os.path.dirname(mo2_path)
# --- Auto-create nxmhandler.ini to suppress NXM Handling popup (MOVED UP) ---
self.shortcut_handler.write_nxmhandler_ini(mo2_dir, mo2_path)
steam_icons_path = os.path.join(mo2_dir, "Steam Icons")
steamicons_path = os.path.join(mo2_dir, "SteamIcons")
if os.path.isdir(steam_icons_path) and not os.path.isdir(steamicons_path):
try:
os.rename(steam_icons_path, steamicons_path)
self.logger.info(f"Renamed 'Steam Icons' to 'SteamIcons' in {mo2_dir}")
except Exception as e:
self.logger.warning(f"Failed to rename 'Steam Icons' to 'SteamIcons': {e}")
self.logger.debug(f"[DEBUG] After normalization, SteamIcons exists: {os.path.isdir(steamicons_path)}")
# --- Use automated prefix workflow (replaces old manual workflow) ---
try:
mo2_dir = os.path.dirname(mo2_path)
install_dir = mo2_dir
# Use automated prefix service for modern workflow
print(f"\n{COLOR_INFO}Using automated Steam setup workflow...{COLOR_RESET}")
from ..services.automated_prefix_service import AutomatedPrefixService
prefix_service = AutomatedPrefixService()
# Define progress callback for CLI with jackify-engine style timestamps
import time
start_time = time.time()
def progress_callback(message):
elapsed = time.time() - start_time
hours = int(elapsed // 3600)
minutes = int((elapsed % 3600) // 60)
seconds = int(elapsed % 60)
timestamp = f"[{hours:02d}:{minutes:02d}:{seconds:02d}]"
print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}")
# Run the automated workflow
result = prefix_service.run_working_workflow(
modlist_name, install_dir, mo2_path, progress_callback, steamdeck=self.steamdeck
)
# Handle the result
if isinstance(result, tuple) and len(result) == 4:
if result[0] == "CONFLICT":
# Handle conflict - ask user what to do
conflicts = result[1]
print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}")
for i, conflict in enumerate(conflicts, 1):
print(f" {i}. Name: {conflict['name']}")
print(f" Executable: {conflict['exe']}")
print(f" Start Directory: {conflict['startdir']}")
print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}")
print(" 1. Use existing shortcut (recommended)")
print(" 2. Create new shortcut anyway")
choice = input(f"{COLOR_PROMPT}Enter choice (1-2): {COLOR_RESET}").strip()
if choice == "1":
# Use existing shortcut
existing_appid = conflicts[0].get('appid')
if existing_appid:
context = {
"name": modlist_name,
"appid": str(existing_appid),
"path": mo2_dir,
"manual_steps_completed": True,
"resolution": None
}
return self.run_modlist_configuration_phase(context)
elif choice == "2":
# Create new shortcut - would need to handle this, but for now just fail
print(f"{COLOR_ERROR}Creating new shortcut with same name not supported in this flow.{COLOR_RESET}")
return True
else:
print(f"{COLOR_ERROR}Invalid choice.{COLOR_RESET}")
return True
else:
# Success - get the results
success, prefix_path, appid_int, last_timestamp = result
if success and appid_int:
context = {
"name": modlist_name,
"appid": str(appid_int),
"path": mo2_dir,
"manual_steps_completed": True,
"resolution": None
}
self.logger.debug(f"[DEBUG] New Modlist Context (automated workflow): {context}")
return self.run_modlist_configuration_phase(context)
else:
print(f"{COLOR_ERROR}Automated workflow completed but no AppID was returned.{COLOR_RESET}")
return True
else:
# Unexpected result format
print(f"{COLOR_ERROR}Automated workflow returned unexpected format.{COLOR_RESET}")
self.logger.error(f"Unexpected result format from automated workflow: {result}")
return True
except Exception as e:
self.logger.error(f"Error creating Steam shortcut: {e}", exc_info=True)
print(f"\n{COLOR_ERROR}Failed to create Steam shortcut: {e}{COLOR_RESET}")
return True
except Exception as e:
self.logger.error(f"Error in _configure_new_modlist: {e}", exc_info=True)
print(f"\n{COLOR_ERROR}Unexpected error in new modlist configuration: {e}{COLOR_RESET}")
return True
def _configure_existing_modlist(self):
"""Handle configuration of an existing modlist. Returns True to continue menu, False to exit."""
logger.info("Detecting installed modlists...")
try:
if not self.modlist_handler:
print("Internal Error: Modlist handler not available.")
input("\nPress Enter to continue...")
return True
configurable_modlists = self.modlist_handler.discover_executable_shortcuts("ModOrganizer.exe")
if not configurable_modlists:
logger.warning("No configurable ModOrganizer modlists found.")
print(f"{COLOR_ERROR}\nCould not find any recognized ModOrganizer modlists.{COLOR_RESET}")
print("Ensure the shortcut exists in Steam, points to ModOrganizer.exe, and has been run once.")
input(f"\n{COLOR_PROMPT}Press Enter to return to menu...{COLOR_RESET}")
return True
selected_modlist_dict = self.select_from_list(configurable_modlists, f"{COLOR_PROMPT}Select Modlist to Configure:{COLOR_RESET}")
if not selected_modlist_dict:
logger.info("Modlist selection cancelled by user.")
return True
logger.info(f"Setting context for selected modlist: {selected_modlist_dict.get('name')}")
context = {
"name": selected_modlist_dict.get("name"),
"appid": selected_modlist_dict.get("appid"),
"path": selected_modlist_dict.get("path"),
"resolution": selected_modlist_dict.get("resolution") if selected_modlist_dict.get("resolution") else None
}
self.logger.debug(f"[DEBUG] Existing Modlist Context: {context}")
return self.run_modlist_configuration_phase(context)
except KeyboardInterrupt:
print("\nConfiguration cancelled by user.")
return True
except Exception as e:
logger.exception(f"Error configuring existing modlist: {e}", exc_info=True)
print(f"{COLOR_ERROR}\nAn unexpected error occurred: {str(e)}{COLOR_RESET}")
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
return True
def select_from_list(self, items: List[Dict], prompt="Select an option") -> Optional[Dict]:
"""
Display a list of items (dictionaries) and let the user select one.
Args:
items: A list of dictionaries, each expected to have at least 'name' and 'appid'.
prompt: The message to display before the list.
Returns:
The selected dictionary item or None if cancelled.
"""
if not items:
print(f"{COLOR_WARNING}No items available to select from.{COLOR_RESET}")
return None
print("\n" + "-" * 28) # Separator
print(f"{COLOR_PROMPT}{prompt}{COLOR_RESET}") # Main prompt message (e.g., "Select Modlist to Configure:")
for i, item_dict in enumerate(items, 1):
display_name = item_dict.get('name', 'Unknown Item')
# Optionally display other relevant info if available, e.g., AppID or path
# For now, keeping it simple with just the name for selection clarity.
print(f" {COLOR_SELECTION}{i}.{COLOR_RESET} {display_name}")
print(f" {COLOR_SELECTION}0.{COLOR_RESET} Cancel selection") # Added cancel option
while True:
try:
choice_input = input(f"{COLOR_PROMPT}Enter your choice (0-{len(items)}): {COLOR_RESET}").strip()
if choice_input.lower() == 'q' or choice_input == '0': # Allow 'q' or '0' for cancel
self.logger.info("User cancelled selection from list.")
print(f"{COLOR_INFO}Selection cancelled.{COLOR_RESET}")
return None
if choice_input.isdigit():
choice_int = int(choice_input)
if 1 <= choice_int <= len(items):
return items[choice_int - 1]
print(f"{COLOR_ERROR}Invalid choice. Please enter a number between 0 and {len(items)}.{COLOR_RESET}")
except ValueError:
print(f"{COLOR_ERROR}Invalid input. Please enter a number.{COLOR_RESET}")
except KeyboardInterrupt:
print("\nSelection cancelled (Ctrl+C).")
self.logger.info("User cancelled selection from list via Ctrl+C.")
return None
def run_modlist_configuration_phase(self, context: dict) -> bool:
"""
Shared configuration phase for both new and existing modlists.
Expects context dict with keys: name, appid, path (at minimum).
"""
self.logger.debug(f"[DEBUG] Entering run_modlist_configuration_phase with context: {context}")
# Robust AppID lookup for GUI/CLI: if appid missing but mo2_exe_path present, look it up
if 'appid' not in context or not context.get('appid'):
if 'mo2_exe_path' in context and context['mo2_exe_path']:
appid = self.shortcut_handler.get_appid_for_shortcut(context['name'], context['mo2_exe_path'])
if appid:
context['appid'] = appid
else:
self.logger.warning(f"[DEBUG] Could not find AppID for {context['name']} with exe {context['mo2_exe_path']}")
set_modlist_result = self.modlist_handler.set_modlist(context)
self.logger.debug(f"[DEBUG] set_modlist returned: {set_modlist_result}")
# Check GUI mode early to avoid input() calls in GUI context
import os
gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
if not set_modlist_result:
print(f"{COLOR_ERROR}\nError setting up context for configuration.{COLOR_RESET}")
self.logger.error(f"set_modlist failed for {context.get('name')}")
if not gui_mode:
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
return False
# --- Resolution selection logic for GUI mode ---
selected_resolution = context.get('resolution', None)
if gui_mode:
# If resolution is provided, set it and do not prompt
if selected_resolution:
self.modlist_handler.selected_resolution = selected_resolution
self.logger.info(f"[GUI MODE] Resolution set from GUI: {selected_resolution}")
else:
# If on Steam Deck, set to 1280x800; else leave unchanged
if self.steamdeck:
self.modlist_handler.selected_resolution = "1280x800"
self.logger.info("[GUI MODE] Steam Deck detected, setting resolution to 1280x800.")
else:
self.logger.info("[GUI MODE] No resolution set, leaving unchanged.")
else:
# CLI mode: prompt as before
print() # Add padding before resolution prompt
selected_res = self.resolution_handler.select_resolution(steamdeck=self.steamdeck)
if selected_res:
self.modlist_handler.selected_resolution = selected_res
self.logger.info(f"Resolution preference set to: {selected_res}")
elif self.steamdeck:
self.modlist_handler.selected_resolution = "1280x800"
self.logger.info(f"Using default Steam Deck resolution: {self.modlist_handler.selected_resolution}")
else:
self.logger.info("User cancelled resolution selection or not applicable.")
skip_confirmation = context.get('skip_confirmation', False)
if gui_mode:
skip_confirmation = True
if not self.modlist_handler.display_modlist_summary(skip_confirmation=skip_confirmation):
self.logger.info("User chose not to proceed with configuration after summary.")
return True
self.logger.info(f"Starting configuration steps for {context.get('name')}")
print() # Add padding before status line
status_line = ""
import os
gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
def update_status(msg):
nonlocal status_line
if status_line:
print("\r" + " " * len(status_line), end="\r")
if gui_mode:
print(msg, flush=True)
else:
status_line = f"\r{COLOR_INFO}{msg}{COLOR_RESET}"
print(status_line, end="", flush=True)
manual_steps_completed = context.get("manual_steps_completed", False)
if not self.modlist_handler._execute_configuration_steps(status_callback=update_status, manual_steps_completed=manual_steps_completed):
if status_line:
print()
self.logger.error(f"Core configuration steps failed for {context.get('name')}")
print(f"{COLOR_ERROR}\nModlist configuration failed. Check logs for details.{COLOR_RESET}")
# Only wait for input in CLI mode, not GUI mode
if not gui_mode:
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
return False
if status_line:
print()
# Configure ENB for Linux compatibility (non-blocking, same as GUI)
enb_detected = False
try:
from ..handlers.enb_handler import ENBHandler
from pathlib import Path
enb_handler = ENBHandler()
install_dir = Path(context.get('path', ''))
if install_dir.exists():
enb_success, enb_message, enb_detected = enb_handler.configure_enb_for_linux(install_dir)
if enb_message:
if enb_success:
self.logger.info(enb_message)
update_status(enb_message)
else:
self.logger.warning(enb_message)
# Non-blocking: continue workflow even if ENB config fails
except Exception as e:
self.logger.warning(f"ENB configuration skipped due to error: {e}")
# Continue workflow - ENB config is optional
# Run modlist-specific post-install automation (e.g., VNV) before showing completion
# Only in CLI mode - GUI handles this in install_modlist.py
if not gui_mode:
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
from pathlib import Path
modlist_name = context.get('name', '')
modlist_path = Path(context.get('path', ''))
try:
print("")
print("Running VNV post-install automation...")
automation_ran, error = run_vnv_automation_if_applicable(
modlist_name=modlist_name,
modlist_install_location=modlist_path,
game_root=None, # Will be auto-detected
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=lambda msg: print(msg),
manual_file_callback=None, # CLI doesn't support manual file callback yet
confirmation_callback=None # Will use default confirmation in CLI
)
if error:
print(f"{COLOR_WARNING}VNV automation encountered an error: {error}{COLOR_RESET}")
print("You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html")
except Exception as e:
self.logger.debug(f"VNV automation check skipped: {e}")
# Not an error - just means VNV automation wasn't applicable
print("")
print("")
print("") # Extra blank line before completion
print("=" * 35)
print("= Configuration phase complete =")
print("=" * 35)
print("")
print("Modlist Install and Configuration complete!")
print(f"• You should now be able to Launch '{context.get('name')}' through Steam")
print("• Congratulations and enjoy the game!")
print("")
# Show ENB-specific warning if ENB was detected (replaces generic note)
if enb_detected:
print(f"{COLOR_WARNING}⚠️ ENB DETECTED{COLOR_RESET}")
print("")
print("If you plan on using ENB as part of this modlist, you will need to use")
print("one of the following Proton versions, otherwise you will have issues:")
print("")
print(" (in order of recommendation)")
print(f" {COLOR_SUCCESS}• Proton-CachyOS{COLOR_RESET}")
print(f" {COLOR_INFO}• GE-Proton 10-14 or lower{COLOR_RESET}")
print(f" {COLOR_WARNING}• Proton 9 from Valve{COLOR_RESET}")
print("")
print(f"{COLOR_WARNING}Note: Valve's Proton 10 has known ENB compatibility issues.{COLOR_RESET}")
print("")
else:
# No ENB detected - no warning needed
pass
from jackify.shared.paths import get_jackify_logs_dir
print(f"Detailed log available at: {get_jackify_logs_dir()}/Configure_New_Modlist_workflow.log")
# Only wait for input in CLI mode, not GUI mode
if not gui_mode:
input(f"{COLOR_PROMPT}Press Enter to return to the menu...{COLOR_RESET}")
return True
from .menu_handler_input import (
basic_input_prompt, input_prompt, simple_path_completer,
READLINE_AVAILABLE, READLINE_HAS_PROMPT, READLINE_HAS_DISPLAY_HOOK,
)
from .menu_handler_modlist import ModlistMenuHandler
class MenuHandler:
"""
@@ -1005,8 +320,7 @@ class MenuHandler:
self.logger.info(f"Selected directory (exists): {chosen_path}")
return chosen_path
else:
self.logger.warning(f"Path exists but is not a directory: {chosen_path}")
print(f"{COLOR_ERROR}Error: Path exists but is not a directory: {chosen_path}{COLOR_RESET}")
print(f"{COLOR_ERROR}Path exists but is not a directory: {chosen_path}{COLOR_RESET}")
if not self._ask_try_again(): return None
continue
elif create_if_missing:
@@ -1072,8 +386,8 @@ class MenuHandler:
print("")
return file_path
else:
print(f"{COLOR_ERROR}Error: Path is not a valid '{extension_filter}' file or a directory: {file_path}{COLOR_RESET}")
print("Please check the path and try again, or press Ctrl+C or 'q' to cancel.")
print(f"{COLOR_ERROR}Path is not a valid '{extension_filter}' file or a directory: {file_path}{COLOR_RESET}")
print(f"{COLOR_INFO}Please check the path and try again, or press Ctrl+C or 'q' to cancel.{COLOR_RESET}")
if not self._ask_try_again():
print("")
return None
@@ -1082,65 +396,5 @@ class MenuHandler:
print("")
return None
finally:
if READLINE_AVAILABLE:
readline.set_completer(None)
# Basic input prompt function for use throughout the application
input_prompt = basic_input_prompt
# --- Robust shell-like path completer function ---
def _shell_path_completer(text, state):
"""
Shell-like pathname completer for readline.
Expands ~, handles absolute/relative paths, and completes inside directories.
"""
import os
import glob
# Expand ~ and environment variables
expanded = os.path.expanduser(os.path.expandvars(text))
# If the expanded path is a directory, list its contents
if os.path.isdir(expanded):
pattern = os.path.join(expanded, '*')
else:
# Complete the last component
pattern = expanded + '*'
matches = glob.glob(pattern)
# Add trailing slash to directories
matches = [m + ('/' if os.path.isdir(m) else '') for m in matches]
# If the user hasn't typed anything, show current dir
if not text:
matches = glob.glob('*')
matches = [m + ('/' if os.path.isdir(m) else '') for m in matches]
# Return the state-th match or None
try:
return matches[state]
except IndexError:
return None
# Create a public reference to the robust completer
simple_path_completer = _shell_path_completer
# --- Simple path completer function ---
def _simple_path_completer(text, state):
"""
Simple pathname completer for readline.
Logic:
- If text is empty (at beginning of line), returns options for current dir
- If text has content, does prefix matching on path components
- Tab completion will fill up to next / or complete the filename
- State is an integer index representing which match to return
Args:
text: The text to complete
state: The state index (0 for first match, 1 for second, etc.)
Returns:
The matching completion or None if no more matches
"""
import glob, os
matches = glob.glob(text + '*')
matches = [f + ('/' if os.path.isdir(f) else '') for f in matches]
try:
return matches[state]
except IndexError:
return None
simple_path_completer = _simple_path_completer
if READLINE_AVAILABLE and readline:
readline.set_completer(None)

View File

@@ -0,0 +1,98 @@
"""
Menu handler input and readline tab completion.
Exports: READLINE_* constants, basic_input_prompt, input_prompt, simple_path_completer, _shell_path_completer, _simple_path_completer.
"""
import logging
import os
import glob
from .ui_colors import COLOR_PROMPT, COLOR_RESET
READLINE_AVAILABLE = False
READLINE_HAS_PROMPT = False
READLINE_HAS_DISPLAY_HOOK = False
try:
import readline
READLINE_AVAILABLE = True
logging.debug("Readline imported for tab completion")
if hasattr(readline, 'set_prompt'):
READLINE_HAS_PROMPT = True
logging.debug("Readline has set_prompt capability")
else:
logging.debug("Readline does not have set_prompt capability, will use fallback")
try:
readline.parse_and_bind('tab: complete')
logging.debug("Readline tab completion successfully configured")
except Exception as e:
logging.warning(f"Error configuring readline tab completion: {e}. Tab completion may be limited.")
if hasattr(readline, 'set_completion_display_matches_hook'):
READLINE_HAS_DISPLAY_HOOK = True
logging.debug("Readline has completion display hook capability")
def custom_display_completions(substitution, matches, longest_match_length):
print()
try:
import shutil
term_width = shutil.get_terminal_size().columns
except (ImportError, AttributeError):
term_width = 80
items_per_line = max(1, term_width // (longest_match_length + 2))
for i, match in enumerate(matches):
print(f"{match:<{longest_match_length + 2}}", end='' if (i + 1) % items_per_line else '\n')
if len(matches) % items_per_line != 0:
print()
current_input = readline.get_line_buffer()
print(f"{COLOR_PROMPT}> {COLOR_RESET}{current_input}", end='', flush=True)
try:
readline.set_completion_display_matches_hook(custom_display_completions)
logging.debug("Custom completion display hook successfully set")
except Exception as e:
logging.warning(f"Error setting completion display hook: {e}. Using default display behavior.")
READLINE_HAS_DISPLAY_HOOK = False
else:
logging.debug("Readline doesn't have completion display hook capability, using default")
except ImportError:
logging.warning("readline not available. Tab completion for paths will be disabled.")
except Exception as e:
logging.warning(f"Error initializing readline: {e}. Tab completion for paths will be disabled.")
def basic_input_prompt(message, **kwargs):
return input(message)
input_prompt = basic_input_prompt
def _shell_path_completer(text, state):
"""Shell-like pathname completer for readline. Expands ~, handles absolute/relative paths."""
expanded = os.path.expanduser(os.path.expandvars(text))
if os.path.isdir(expanded):
pattern = os.path.join(expanded, '*')
else:
pattern = expanded + '*'
matches = glob.glob(pattern)
matches = [m + ('/' if os.path.isdir(m) else '') for m in matches]
if not text:
matches = glob.glob('*')
matches = [m + ('/' if os.path.isdir(m) else '') for m in matches]
try:
return matches[state]
except IndexError:
return None
def _simple_path_completer(text, state):
"""Simple pathname completer for readline. Prefix matching on path components."""
matches = glob.glob(text + '*')
matches = [f + ('/' if os.path.isdir(f) else '') for f in matches]
try:
return matches[state]
except IndexError:
return None
simple_path_completer = _simple_path_completer

View File

@@ -0,0 +1,615 @@
"""
Modlist menu handler: modlist-specific CLI menu operations.
ModlistMenuHandler class. Lazy-imports MenuHandler to avoid circular import.
"""
import logging
import os
from pathlib import Path
from typing import List, Dict, Optional
from .ui_colors import (
COLOR_PROMPT, COLOR_SELECTION, COLOR_RESET, COLOR_INFO, COLOR_ERROR,
COLOR_SUCCESS, COLOR_WARNING, COLOR_ACTION, COLOR_INPUT
)
from .modlist_handler import ModlistHandler
from .filesystem_handler import FileSystemHandler
from .path_handler import PathHandler
from .vdf_handler import VDFHandler
from .resolution_handler import ResolutionHandler
from jackify.shared.ui_utils import print_section_header
logger = logging.getLogger(__name__)
class ModlistMenuHandler:
"""Handles modlist-specific menu operations."""
def __init__(self, config_handler, test_mode=False):
self.config_handler = config_handler
self.test_mode = test_mode
self.exit_flag = False
self.logger = logging.getLogger(__name__)
try:
self.filesystem_handler = FileSystemHandler()
self.path_handler = PathHandler()
self.vdf_handler = VDFHandler()
from ..services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
self.steamdeck = platform_service.is_steamdeck
self.resolution_handler = ResolutionHandler()
from .menu_handler import MenuHandler
self.menu_handler = MenuHandler()
self.modlist_handler = ModlistHandler(
self.config_handler.settings,
steamdeck=self.steamdeck,
verbose=False,
filesystem_handler=self.filesystem_handler
)
self.shortcut_handler = self.modlist_handler.shortcut_handler
self.install_wabbajack_handler = None
except Exception as e:
self.logger.error(f"Error initializing ModlistMenuHandler: {e}")
self.filesystem_handler = FileSystemHandler()
try:
from ..services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
self.steamdeck = platform_service.is_steamdeck
except Exception:
self.steamdeck = False
self.modlist_handler = None
def show_modlist_menu(self):
while True:
os.system('cls' if os.name == 'nt' else 'clear')
# Banner display handled by frontend
print_section_header('Modlist Configuration')
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Configure a New modlist not yet in Steam")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure a modlist already in Steam")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}")
if choice == "1":
if not self._configure_new_modlist():
return False
elif choice == "2":
if not self._configure_existing_modlist():
return False
elif choice == "0":
logger.info("Returning to main menu from Modlist Configuration menu.")
return False
else:
logger.warning(f"Invalid menu selection: {choice}")
print("\nInvalid selection. Please try again.")
input("\nPress Enter to continue...")
def _display_manual_proton_steps(self, modlist_name):
"""Displays the detailed manual steps required for Proton setup."""
# Keep these as print for clear user instructions
print(f"\n{COLOR_PROMPT}--- Manual Proton Setup Required ---{COLOR_RESET}")
print("Please complete the following steps in Steam:")
print(f" 1. Locate the '{COLOR_INFO}{modlist_name}{COLOR_RESET}' entry in your Steam Library")
print(" 2. Right-click and select 'Properties'")
print(" 3. Switch to the 'Compatibility' tab")
print(" 4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'")
print(" 5. Select 'Proton - Experimental' from the dropdown menu")
print(" 6. Close the Properties window")
print(f" 7. Launch '{COLOR_INFO}{modlist_name}{COLOR_RESET}' from your Steam Library")
print(" 8. If Mod Organizer opens or produces any error message, that's normal")
print(" 9. No matter what,CLOSE Mod Organizer completely and return here")
print(f"{COLOR_PROMPT}------------------------------------{COLOR_RESET}")
def _get_mo2_path(self) -> Optional[str]:
"""
Get the path to ModOrganizer.exe from user input.
Returns the validated path or None if cancelled/invalid.
"""
self.logger.info("Prompting for ModOrganizer.exe path...")
print("\n" + "-" * 28) # Separator
print(f"{COLOR_PROMPT}Please provide the path to ModOrganizer.exe for your modlist.{COLOR_RESET}")
print(f"{COLOR_INFO}This is typically found in the modlist's installation directory.")
print(f"{COLOR_INFO}Example: ~/Games/MyModlist/ModOrganizer.exe")
print(f"{COLOR_INFO}You can also provide the path to the directory containing ModOrganizer.exe.")
# Use the menu_handler's get_existing_file_path for consistency if self.menu_handler is available
# self.menu_handler is MenuHandler, not ModlistMenuHandler
if hasattr(self, 'menu_handler') and self.menu_handler is not None:
# get_existing_file_path will use its own standard prompting style internally
# We pass no_header=False so it shows its full prompt.
# The prompt_message here becomes the main instruction for get_existing_file_path.
path_result = self.menu_handler.get_existing_file_path(
prompt_message=f"Path to ModOrganizer.exe or its directory",
extension_filter=".exe",
no_header=False # Let get_existing_file_path handle its full prompt including separator
)
if path_result is None: # User cancelled
self.logger.info("User cancelled ModOrganizer.exe path input via get_existing_file_path.")
return None
path_str = str(path_result)
if os.path.isdir(path_str):
potential_mo2_path = os.path.join(path_str, "ModOrganizer.exe")
if os.path.isfile(potential_mo2_path):
self.logger.info(f"Found ModOrganizer.exe in directory: {potential_mo2_path}")
return potential_mo2_path
else:
print(f"{COLOR_ERROR}ModOrganizer.exe not found in directory: {path_str}{COLOR_RESET}")
# Allow to try again - this might need a loop or rely on get_existing_file_path loop
return self._get_mo2_path() # Recursive call to try again, simple loop better
elif os.path.isfile(path_str) and os.path.basename(path_str).lower() == "modorganizer.exe":
self.logger.info(f"ModOrganizer.exe path validated: {path_str}")
return path_str
else:
print(f"{COLOR_ERROR}Path is not ModOrganizer.exe or a directory containing it.{COLOR_RESET}")
return self._get_mo2_path() # Recursive call
# Fallback to basic input if self.menu_handler is not available (should ideally not happen)
self.logger.warning("_get_mo2_path: self.menu_handler not available, using basic input as fallback.")
while True:
try:
# Basic input prompt if menu_handler isn't used
mo2_path_input = input(f"{COLOR_PROMPT}Enter path to ModOrganizer.exe (or 'q' to cancel): {COLOR_RESET}").strip()
if mo2_path_input.lower() == 'q':
self.logger.info("User cancelled ModOrganizer.exe path input (fallback).")
return None
expanded_path = os.path.expanduser(mo2_path_input)
normalized_path = os.path.normpath(expanded_path)
if os.path.isdir(normalized_path):
potential_mo2_path = os.path.join(normalized_path, "ModOrganizer.exe")
if os.path.isfile(potential_mo2_path):
self.logger.info(f"Found ModOrganizer.exe in directory (fallback): {potential_mo2_path}")
return potential_mo2_path
else:
print(f"{COLOR_ERROR}ModOrganizer.exe not found in directory: {normalized_path}{COLOR_RESET}")
continue
if not normalized_path.lower().endswith('modorganizer.exe'):
print(f"{COLOR_ERROR}Path must be ModOrganizer.exe or a directory containing it.{COLOR_RESET}")
continue
if not os.path.isfile(normalized_path):
print(f"{COLOR_ERROR}File does not exist: {normalized_path}{COLOR_RESET}")
continue
self.logger.info(f"ModOrganizer.exe path validated (fallback): {normalized_path}")
return normalized_path
except KeyboardInterrupt:
print("\nOperation cancelled.")
self.logger.info("User cancelled ModOrganizer.exe path input via Ctrl+C (fallback).")
return None
except Exception as e:
self.logger.error(f"Error processing ModOrganizer.exe path (fallback): {e}")
print(f"{COLOR_ERROR}An error occurred: {e}{COLOR_RESET}")
return None
def _get_modlist_name(self) -> Optional[str]:
"""
Get the modlist name from user input.
Returns the validated name or None if cancelled.
"""
self.logger.info("Prompting for modlist name...")
print("\n" + "-" * 28) # Separator
print(f"{COLOR_PROMPT}Please provide a name for your modlist.{COLOR_RESET}")
print(f"{COLOR_INFO}(This will be the name used for the Steam shortcut.){COLOR_RESET}")
while True:
try:
modlist_name = input(f"{COLOR_PROMPT}Modlist Name (or 'q' to cancel): {COLOR_RESET}").strip()
if modlist_name.lower() == 'q':
self.logger.info("User cancelled modlist name input.")
return None
if not modlist_name:
print(f"{COLOR_ERROR}Name cannot be empty.{COLOR_RESET}")
continue
if len(modlist_name) > 100:
print(f"{COLOR_ERROR}Name is too long (max 100 characters).{COLOR_RESET}")
continue
invalid_chars = '< > : " / \\ | ? *' # String of invalid chars for message
if any(char in modlist_name for char in invalid_chars.replace(' ','')):
print(f"{COLOR_ERROR}Name contains invalid characters (e.g., {invalid_chars}).{COLOR_RESET}")
continue
self.logger.info(f"Modlist name validated: {modlist_name}")
return modlist_name
except KeyboardInterrupt:
print("\nOperation cancelled.")
self.logger.info("User cancelled modlist name input via Ctrl+C.")
return None
except Exception as e:
self.logger.error(f"Error processing modlist name: {e}")
print(f"{COLOR_ERROR}An error occurred: {e}{COLOR_RESET}")
return None
def _configure_new_modlist(self, default_modlist_dir=None, default_modlist_name=None):
"""Handle configuration of a new modlist. Returns True to continue menu, False to exit."""
# --- Get ModOrganizer.exe Path ---
if default_modlist_dir:
# Try to infer ModOrganizer.exe path
mo2_path = os.path.join(default_modlist_dir, "ModOrganizer.exe")
if not os.path.isfile(mo2_path):
print(f"{COLOR_ERROR}Could not find ModOrganizer.exe in {default_modlist_dir}{COLOR_RESET}")
mo2_path = self._get_mo2_path()
else:
mo2_path = self._get_mo2_path()
if not mo2_path:
return True
# --- Get Modlist Name ---
if default_modlist_name:
modlist_name = default_modlist_name
else:
modlist_name = self._get_modlist_name()
if not modlist_name:
return True
# Add a blank line for padding
print("")
try:
# --- Ensure SteamIcons directory is normalized before icon selection ---
mo2_dir = os.path.dirname(mo2_path)
# --- Auto-create nxmhandler.ini to suppress NXM Handling popup (MOVED UP) ---
self.shortcut_handler.write_nxmhandler_ini(mo2_dir, mo2_path)
steam_icons_path = os.path.join(mo2_dir, "Steam Icons")
steamicons_path = os.path.join(mo2_dir, "SteamIcons")
if os.path.isdir(steam_icons_path) and not os.path.isdir(steamicons_path):
try:
os.rename(steam_icons_path, steamicons_path)
self.logger.info(f"Renamed 'Steam Icons' to 'SteamIcons' in {mo2_dir}")
except Exception as e:
self.logger.warning(f"Failed to rename 'Steam Icons' to 'SteamIcons': {e}")
self.logger.debug(f"[DEBUG] After normalization, SteamIcons exists: {os.path.isdir(steamicons_path)}")
# --- Use automated prefix workflow (replaces old manual workflow) ---
try:
mo2_dir = os.path.dirname(mo2_path)
install_dir = mo2_dir
# Use automated prefix service for modern workflow
print(f"\n{COLOR_INFO}Using automated Steam setup workflow...{COLOR_RESET}")
from ..services.automated_prefix_service import AutomatedPrefixService
prefix_service = AutomatedPrefixService()
# Define progress callback for CLI with jackify-engine style timestamps
import time
start_time = time.time()
def progress_callback(message):
elapsed = time.time() - start_time
hours = int(elapsed // 3600)
minutes = int((elapsed % 3600) // 60)
seconds = int(elapsed % 60)
timestamp = f"[{hours:02d}:{minutes:02d}:{seconds:02d}]"
print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}")
# Run the automated workflow
result = prefix_service.run_working_workflow(
modlist_name, install_dir, mo2_path, progress_callback, steamdeck=self.steamdeck
)
# Handle the result
if isinstance(result, tuple) and len(result) == 4:
if result[0] == "CONFLICT":
# Handle conflict - ask user what to do
conflicts = result[1]
print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}")
for i, conflict in enumerate(conflicts, 1):
print(f" {i}. Name: {conflict['name']}")
print(f" Executable: {conflict['exe']}")
print(f" Start Directory: {conflict['startdir']}")
print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}")
print(" 1. Use existing shortcut (recommended)")
print(" 2. Create new shortcut anyway")
choice = input(f"{COLOR_PROMPT}Enter choice (1-2): {COLOR_RESET}").strip()
if choice == "1":
# Use existing shortcut
existing_appid = conflicts[0].get('appid')
if existing_appid:
context = {
"name": modlist_name,
"appid": str(existing_appid),
"path": mo2_dir,
"manual_steps_completed": True,
"resolution": None
}
return self.run_modlist_configuration_phase(context)
elif choice == "2":
# Create new shortcut - would need to handle this, but for now just fail
print(f"{COLOR_ERROR}Creating new shortcut with same name not supported in this flow.{COLOR_RESET}")
return True
else:
print(f"{COLOR_ERROR}Invalid choice.{COLOR_RESET}")
return True
else:
# Success - get the results
success, prefix_path, appid_int, last_timestamp = result
if success and appid_int:
context = {
"name": modlist_name,
"appid": str(appid_int),
"path": mo2_dir,
"manual_steps_completed": True,
"resolution": None
}
self.logger.debug(f"[DEBUG] New Modlist Context (automated workflow): {context}")
return self.run_modlist_configuration_phase(context)
else:
print(f"{COLOR_ERROR}Automated workflow completed but no AppID was returned.{COLOR_RESET}")
return True
else:
# Unexpected result format
print(f"{COLOR_ERROR}Automated workflow returned unexpected format.{COLOR_RESET}")
self.logger.error(f"Unexpected result format from automated workflow: {result}")
return True
except Exception as e:
self.logger.error(f"Error creating Steam shortcut: {e}", exc_info=True)
print(f"\n{COLOR_ERROR}Failed to create Steam shortcut: {e}{COLOR_RESET}")
return True
except Exception as e:
self.logger.error(f"Error in _configure_new_modlist: {e}", exc_info=True)
print(f"\n{COLOR_ERROR}Unexpected error in new modlist configuration: {e}{COLOR_RESET}")
return True
def _configure_existing_modlist(self):
"""Handle configuration of an existing modlist. Returns True to continue menu, False to exit."""
logger.info("Detecting installed modlists...")
try:
if not self.modlist_handler:
logger.error("Internal Error: Modlist handler not available.")
input("\nPress Enter to continue...")
return True
configurable_modlists = self.modlist_handler.discover_executable_shortcuts("ModOrganizer.exe")
if not configurable_modlists:
logger.warning("No configurable ModOrganizer modlists found.")
print(f"{COLOR_ERROR}\nCould not find any recognized ModOrganizer modlists.{COLOR_RESET}")
print("Ensure the shortcut exists in Steam, points to ModOrganizer.exe, and has been run once.")
input(f"\n{COLOR_PROMPT}Press Enter to return to menu...{COLOR_RESET}")
return True
selected_modlist_dict = self.select_from_list(configurable_modlists, f"{COLOR_PROMPT}Select Modlist to Configure:{COLOR_RESET}")
if not selected_modlist_dict:
logger.info("Modlist selection cancelled by user.")
return True
logger.info(f"Setting context for selected modlist: {selected_modlist_dict.get('name')}")
context = {
"name": selected_modlist_dict.get("name"),
"appid": selected_modlist_dict.get("appid"),
"path": selected_modlist_dict.get("path"),
"resolution": selected_modlist_dict.get("resolution") if selected_modlist_dict.get("resolution") else None,
"modlist_source": "existing" # Mark as existing modlist to skip manual steps
}
self.logger.debug(f"[DEBUG] Existing Modlist Context: {context}")
return self.run_modlist_configuration_phase(context)
except KeyboardInterrupt:
print("\nConfiguration cancelled by user.")
return True
except Exception as e:
logger.exception(f"Error configuring existing modlist: {e}", exc_info=True)
print(f"{COLOR_ERROR}\nAn unexpected error occurred: {str(e)}{COLOR_RESET}")
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
return True
def select_from_list(self, items: List[Dict], prompt="Select an option") -> Optional[Dict]:
"""
Display a list of items (dictionaries) and let the user select one.
Args:
items: A list of dictionaries, each expected to have at least 'name' and 'appid'.
prompt: The message to display before the list.
Returns:
The selected dictionary item or None if cancelled.
"""
if not items:
print(f"{COLOR_WARNING}No items available to select from.{COLOR_RESET}")
return None
print("\n" + "-" * 28) # Separator
print(f"{COLOR_PROMPT}{prompt}{COLOR_RESET}") # Main prompt message (e.g., "Select Modlist to Configure:")
for i, item_dict in enumerate(items, 1):
display_name = item_dict.get('name', 'Unknown Item')
# Optionally display other relevant info if available, e.g., AppID or path
# For now, keeping it simple with just the name for selection clarity.
print(f" {COLOR_SELECTION}{i}.{COLOR_RESET} {display_name}")
print(f" {COLOR_SELECTION}0.{COLOR_RESET} Cancel selection") # Added cancel option
while True:
try:
choice_input = input(f"{COLOR_PROMPT}Enter your choice (0-{len(items)}): {COLOR_RESET}").strip()
if choice_input.lower() == 'q' or choice_input == '0': # Allow 'q' or '0' for cancel
self.logger.info("User cancelled selection from list.")
print(f"{COLOR_INFO}Selection cancelled.{COLOR_RESET}")
return None
if choice_input.isdigit():
choice_int = int(choice_input)
if 1 <= choice_int <= len(items):
return items[choice_int - 1]
print(f"{COLOR_ERROR}Invalid choice. Please enter a number between 0 and {len(items)}.{COLOR_RESET}")
except ValueError:
print(f"{COLOR_ERROR}Invalid input. Please enter a number.{COLOR_RESET}")
except KeyboardInterrupt:
print("\nSelection cancelled (Ctrl+C).")
self.logger.info("User cancelled selection from list via Ctrl+C.")
return None
def run_modlist_configuration_phase(self, context: dict) -> bool:
"""
Shared configuration phase for both new and existing modlists.
Expects context dict with keys: name, appid, path (at minimum).
"""
self.logger.debug(f"[DEBUG] Entering run_modlist_configuration_phase with context: {context}")
# Robust AppID lookup for GUI/CLI: if appid missing but mo2_exe_path present, look it up
if 'appid' not in context or not context.get('appid'):
if 'mo2_exe_path' in context and context['mo2_exe_path']:
appid = self.shortcut_handler.get_appid_for_shortcut(context['name'], context['mo2_exe_path'])
if appid:
context['appid'] = appid
else:
self.logger.warning(f"[DEBUG] Could not find AppID for {context['name']} with exe {context['mo2_exe_path']}")
set_modlist_result = self.modlist_handler.set_modlist(context)
self.logger.debug(f"[DEBUG] set_modlist returned: {set_modlist_result}")
# Check GUI mode early to avoid input() calls in GUI context
import os
gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
if not set_modlist_result:
print(f"{COLOR_ERROR}\nError setting up context for configuration.{COLOR_RESET}")
self.logger.error(f"set_modlist failed for {context.get('name')}")
if not gui_mode:
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
return False
# --- Resolution selection logic for GUI mode ---
selected_resolution = context.get('resolution', None)
if gui_mode:
# If resolution is provided, set it and do not prompt
if selected_resolution:
self.modlist_handler.selected_resolution = selected_resolution
self.logger.info(f"[GUI MODE] Resolution set from GUI: {selected_resolution}")
else:
# If on Steam Deck, set to 1280x800; else leave unchanged
if self.steamdeck:
self.modlist_handler.selected_resolution = "1280x800"
self.logger.info("[GUI MODE] Steam Deck detected, setting resolution to 1280x800.")
else:
self.logger.info("[GUI MODE] No resolution set, leaving unchanged.")
else:
# CLI mode: prompt as before
print() # Add padding before resolution prompt
selected_res = self.resolution_handler.select_resolution(steamdeck=self.steamdeck)
if selected_res:
self.modlist_handler.selected_resolution = selected_res
self.logger.info(f"Resolution preference set to: {selected_res}")
elif self.steamdeck:
self.modlist_handler.selected_resolution = "1280x800"
self.logger.info(f"Using default Steam Deck resolution: {self.modlist_handler.selected_resolution}")
else:
self.logger.info("User cancelled resolution selection or not applicable.")
skip_confirmation = context.get('skip_confirmation', False)
if gui_mode:
skip_confirmation = True
if not self.modlist_handler.display_modlist_summary(skip_confirmation=skip_confirmation):
self.logger.info("User chose not to proceed with configuration after summary.")
return True
self.logger.info(f"Starting configuration steps for {context.get('name')}")
print() # Add padding before status line
status_line = ""
import os
gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
def update_status(msg):
nonlocal status_line
if status_line:
print("\r" + " " * len(status_line), end="\r")
if gui_mode:
print(msg, flush=True)
else:
status_line = f"\r{COLOR_INFO}{msg}{COLOR_RESET}"
print(status_line, end="", flush=True)
manual_steps_completed = context.get("manual_steps_completed", False)
skip_manual_for_existing = context.get("modlist_source") == "existing" # Existing modlists skip manual steps
if not self.modlist_handler._execute_configuration_steps(status_callback=update_status, manual_steps_completed=manual_steps_completed, skip_manual_for_existing=skip_manual_for_existing):
if status_line:
print()
self.logger.error(f"Core configuration steps failed for {context.get('name')}")
print(f"{COLOR_ERROR}\nModlist configuration failed. Check logs for details.{COLOR_RESET}")
# Only wait for input in CLI mode, not GUI mode
if not gui_mode:
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
return False
if status_line:
print()
# Configure ENB for Linux compatibility (non-blocking, same as GUI)
enb_detected = False
try:
from ..handlers.enb_handler import ENBHandler
from pathlib import Path
enb_handler = ENBHandler()
install_dir = Path(context.get('path', ''))
if install_dir.exists():
enb_success, enb_message, enb_detected = enb_handler.configure_enb_for_linux(install_dir)
if enb_message:
if enb_success:
self.logger.info(enb_message)
update_status(enb_message)
else:
self.logger.warning(enb_message)
# Non-blocking: continue workflow even if ENB config fails
except Exception as e:
self.logger.warning(f"ENB configuration skipped due to error: {e}")
# Continue workflow - ENB config is optional
# Run modlist-specific post-install automation (e.g., VNV) before showing completion
# Only in CLI mode - GUI handles this in install_modlist.py
if not gui_mode:
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
from pathlib import Path
modlist_name = context.get('name', '')
modlist_path = Path(context.get('path', ''))
try:
print("")
print("Running VNV post-install automation...")
automation_ran, error = run_vnv_automation_if_applicable(
modlist_name=modlist_name,
modlist_install_location=modlist_path,
game_root=None, # Will be auto-detected
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=lambda msg: print(msg),
manual_file_callback=None, # CLI doesn't support manual file callback yet
confirmation_callback=None # Will use default confirmation in CLI
)
if error:
print(f"{COLOR_WARNING}VNV automation encountered an error: {error}{COLOR_RESET}")
print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}")
except Exception as e:
self.logger.debug(f"VNV automation check skipped: {e}")
# Not an error - just means VNV automation wasn't applicable
print("")
print("")
print("") # Extra blank line before completion
print("=" * 35)
print("= Configuration phase complete =")
print("=" * 35)
print("")
print("Modlist Install and Configuration complete!")
print(f"• You should now be able to Launch '{context.get('name')}' through Steam")
print("• Congratulations and enjoy the game!")
print("")
# Show ENB-specific warning if ENB was detected (replaces generic note)
if enb_detected:
print(f"{COLOR_WARNING}ENB DETECTED{COLOR_RESET}")
print("")
print("If you plan on using ENB as part of this modlist, you will need to use")
print("one of the following Proton versions, otherwise you will have issues:")
print("")
print(" (in order of recommendation)")
print(f" {COLOR_SUCCESS}• Proton-CachyOS{COLOR_RESET}")
print(f" {COLOR_INFO}• GE-Proton 10-14 or lower{COLOR_RESET}")
print(f" {COLOR_WARNING}• Proton 9 from Valve{COLOR_RESET}")
print("")
print(f"{COLOR_WARNING}Note: Valve's Proton 10 has known ENB compatibility issues.{COLOR_RESET}")
print("")
else:
# No ENB detected - no warning needed
pass
from jackify.shared.paths import get_jackify_logs_dir
print(f"Detailed log available at: {get_jackify_logs_dir()}/Configure_New_Modlist_workflow.log")
# Only wait for input in CLI mode, not GUI mode
if not gui_mode:
input(f"{COLOR_PROMPT}Press Enter to return to the menu...{COLOR_RESET}")
return True

View File

@@ -5,10 +5,13 @@ from pathlib import Path
import re
import time
import os
import logging
from .ui_colors import COLOR_PROMPT, COLOR_SELECTION, COLOR_RESET, COLOR_INFO, COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING
from .status_utils import show_status, clear_status
from jackify.shared.ui_utils import print_section_header, print_subsection_header
logger = logging.getLogger(__name__)
class MO2Handler:
"""
Handles downloading and installing Mod Organizer 2 (MO2) using system 7z.
@@ -17,6 +20,7 @@ class MO2Handler:
self.menu_handler = menu_handler
# Import shortcut handler from menu_handler if available
self.shortcut_handler = getattr(menu_handler, 'shortcut_handler', None)
self.logger = logging.getLogger(__name__)
def _is_dangerous_path(self, path: Path) -> bool:
# Block /, /home, /root, and the user's home directory
@@ -30,7 +34,7 @@ class MO2Handler:
print_section_header('Mod Organizer 2 Installation')
# 1. Check for 7z
if not shutil.which('7z'):
print(f"{COLOR_ERROR}[ERROR] 7z is not installed. Please install it (e.g., sudo apt install p7zip-full).{COLOR_RESET}\n")
print(f"{COLOR_ERROR}[ERROR] 7z is not installed. Please install it (e.g., sudo apt install p7zip-full).{COLOR_RESET}")
return False
# 2. Prompt for install location
default_dir = Path.home() / "ModOrganizer2"
@@ -64,12 +68,12 @@ class MO2Handler:
install_dir.mkdir(parents=True, exist_ok=True)
show_status(f"Created directory: {install_dir}")
except Exception as e:
print(f"{COLOR_ERROR}[ERROR] Could not create directory: {e}{COLOR_RESET}\n")
print(f"{COLOR_ERROR}[ERROR] Could not create directory: {e}{COLOR_RESET}")
return False
else:
files = list(install_dir.iterdir())
if files:
print(f"Warning: The directory '{install_dir}' is not empty.")
print(f"{COLOR_WARNING}The directory '{install_dir}' is not empty.{COLOR_RESET}")
print("Warning: This will permanently delete all files in the folder. Type 'DELETE' to confirm:")
confirm = input("").strip()
if confirm != 'DELETE':
@@ -92,7 +96,7 @@ class MO2Handler:
response.raise_for_status()
release = response.json()
except Exception as e:
print(f"{COLOR_ERROR}[ERROR] Failed to fetch MO2 release info: {e}{COLOR_RESET}\n")
print(f"{COLOR_ERROR}[ERROR] Failed to fetch MO2 release info: {e}{COLOR_RESET}")
return False
# 6. Find the correct .7z asset (exclude -pdbs, -src, etc)
@@ -103,7 +107,7 @@ class MO2Handler:
asset = a
break
if not asset:
print(f"{COLOR_ERROR}[ERROR] Could not find main MO2 .7z asset in latest release.{COLOR_RESET}\n")
print(f"{COLOR_ERROR}[ERROR] Could not find main MO2 .7z asset in latest release.{COLOR_RESET}")
return False
# 7. Download the archive
@@ -116,7 +120,7 @@ class MO2Handler:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
except Exception as e:
print(f"{COLOR_ERROR}[ERROR] Failed to download MO2 archive: {e}{COLOR_RESET}\n")
print(f"{COLOR_ERROR}[ERROR] Failed to download MO2 archive: {e}{COLOR_RESET}")
return False
# 8. Extract using 7z (suppress noisy output)
@@ -124,16 +128,16 @@ class MO2Handler:
try:
result = subprocess.run(['7z', 'x', str(archive_path), f'-o{install_dir}'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if result.returncode != 0:
print(f"{COLOR_ERROR}[ERROR] Extraction failed: {result.stderr.decode(errors='ignore')}{COLOR_RESET}\n")
print(f"{COLOR_ERROR}[ERROR] Extraction failed: {result.stderr.decode(errors='ignore')}{COLOR_RESET}")
return False
except Exception as e:
print(f"{COLOR_ERROR}[ERROR] Extraction failed: {e}{COLOR_RESET}\n")
print(f"{COLOR_ERROR}[ERROR] Extraction failed: {e}{COLOR_RESET}")
return False
# 9. Validate extraction
mo2_exe = next(install_dir.glob('**/ModOrganizer.exe'), None)
if not mo2_exe:
print(f"{COLOR_ERROR}[ERROR] ModOrganizer.exe not found after extraction. Please check extraction.{COLOR_RESET}\n")
print(f"{COLOR_ERROR}[ERROR] ModOrganizer.exe not found after extraction. Please check extraction.{COLOR_RESET}")
return False
else:
show_status(f"MO2 installed at: {mo2_exe.parent}")
@@ -154,7 +158,7 @@ class MO2Handler:
proton_version="proton_experimental"
)
if not success or not app_id:
print(f"{COLOR_ERROR}[ERROR] Failed to create Steam shortcut.{COLOR_RESET}\n")
print(f"{COLOR_ERROR}[ERROR] Failed to create Steam shortcut.{COLOR_RESET}")
else:
show_status(f"Steam shortcut created for '{COLOR_INFO}{shortcut_name}{COLOR_RESET}'.")
# Restart Steam and show manual steps (reuse logic from Configure Modlist)
@@ -178,7 +182,7 @@ class MO2Handler:
print(" 9. CLOSE Mod Organizer completely and return here")
print("───────────────────────────────────────────────────────────────────\n")
except Exception as e:
print(f"{COLOR_ERROR}[ERROR] Failed to create Steam shortcut: {e}{COLOR_RESET}\n")
print(f"{COLOR_ERROR}[ERROR] Failed to create Steam shortcut: {e}{COLOR_RESET}")
print(f"{COLOR_SUCCESS}Mod Organizer 2 has been installed successfully!{COLOR_RESET}\n")
return True

View File

@@ -0,0 +1,584 @@
"""Configuration workflow methods for ModlistHandler (Mixin)."""
from pathlib import Path
import os
import logging
import requests
import re
from typing import Optional
from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR
from .resolution_handler import ResolutionHandler
logger = logging.getLogger(__name__)
class ModlistConfigurationMixin:
"""Mixin providing configuration workflow methods for ModlistHandler."""
def display_modlist_summary(self, skip_confirmation: bool = False) -> bool:
"""Display the detected modlist summary and ask for confirmation."""
if not self.appid or not self.modlist_dir or not self.modlist_ini:
logger.error("Cannot display summary: Missing essential modlist context.")
return False
# Detect potentially missing info if not already set
if not self.game_name:
self._detect_game_variables()
if not self.proton_ver or self.proton_ver == "Unknown":
self._detect_proton_version()
# Don't reset timing - continue from Steam Integration timing
print("=== Configuration Summary ===")
print(f"{self._get_progress_timestamp()} Selected Modlist: {self.game_name}")
print(f"{self._get_progress_timestamp()} Game Type: {self.game_var_full if self.game_var_full else 'Unknown'}")
print(f"{self._get_progress_timestamp()} Steam App ID: {self.appid}")
print(f"{self._get_progress_timestamp()} Modlist Directory: {self.modlist_dir}")
print(f"{self._get_progress_timestamp()} ModOrganizer.ini: {self.modlist_dir}/ModOrganizer.ini")
print(f"{self._get_progress_timestamp()} Proton Version: {self.proton_ver if self.proton_ver else 'Unknown'}")
print(f"{self._get_progress_timestamp()} Resolution: {self.selected_resolution if self.selected_resolution else 'Default'}")
print(f"{self._get_progress_timestamp()} Modlist on SD Card: {self.modlist_sdcard}")
print("")
if skip_confirmation:
return True
# Ask for confirmation
proceed = input(f"{COLOR_PROMPT}Proceed with configuration? (Y/n): {COLOR_RESET}").lower()
if proceed == 'n': # Now defaults to Yes unless 'n' is entered
logger.info("Configuration cancelled by user after summary.")
return False
else:
return True
def _execute_configuration_steps(self, status_callback=None, manual_steps_completed=False, skip_manual_for_existing=False):
"""
Runs the actual configuration steps for the selected modlist.
Args:
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).
skip_manual_for_existing (bool): If True, always skip manual steps (for existing modlists that are already configured).
"""
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).")
self.logger.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
if status_callback:
# Reset timing for Prefix Configuration section
from jackify.shared.timing import start_new_phase
start_new_phase()
status_callback("") # Blank line after Configuration Summary
status_callback("") # Extra blank line before Prefix Configuration
status_callback("=== Prefix Configuration ===")
status_callback(f"{self._get_progress_timestamp()} Setting Protontricks permissions")
self.logger.info("Step 1: Setting Protontricks permissions...")
if not self.protontricks_handler.set_protontricks_permissions(self.modlist_dir, self.steamdeck):
self.logger.error("Failed to set Protontricks permissions. Configuration aborted.")
self.logger.error("Could not set necessary Protontricks permissions.")
return False # Abort on failure
self.logger.info("Step 1: Setting Protontricks permissions... Done")
# Step 2: Prompt user for manual steps and wait for compatdata
skip_manual_prompt = skip_manual_for_existing # Existing modlists skip manual steps
if not manual_steps_completed and not skip_manual_for_existing:
# Check if Proton Experimental is already set and compatdata exists
proton_ok = False
compatdata_ok = False
# Check Proton version
self.logger.debug(f"[MANUAL STEPS DEBUG] Checking Proton version for AppID {self.appid}")
if self._detect_proton_version():
self.logger.debug(f"[MANUAL STEPS DEBUG] Detected Proton version: {self.proton_ver}")
if self.proton_ver and 'experimental' in self.proton_ver.lower():
proton_ok = True
self.logger.debug("[MANUAL STEPS DEBUG] Proton Experimental detected - proton_ok = True")
else:
self.logger.debug("[MANUAL STEPS DEBUG] Could not detect Proton version")
# Check compatdata/prefix
prefix_path_str = self.path_handler.find_compat_data(str(self.appid))
self.logger.debug(f"[MANUAL STEPS DEBUG] Compatdata path search result: {prefix_path_str}")
if prefix_path_str and os.path.isdir(prefix_path_str):
compatdata_ok = True
self.logger.debug("[MANUAL STEPS DEBUG] Compatdata directory exists - compatdata_ok = True")
else:
self.logger.debug("[MANUAL STEPS DEBUG] Compatdata directory does not exist")
self.logger.debug(f"[MANUAL STEPS DEBUG] proton_ok: {proton_ok}, compatdata_ok: {compatdata_ok}")
if proton_ok and compatdata_ok:
self.logger.info("Proton Experimental and compatdata already set for this AppID; skipping manual steps prompt.")
skip_manual_prompt = True
else:
self.logger.debug("[MANUAL STEPS DEBUG] Manual steps will be required")
self.logger.debug(f"[MANUAL STEPS DEBUG] manual_steps_completed: {manual_steps_completed}, skip_manual_prompt: {skip_manual_prompt}")
if not manual_steps_completed and not skip_manual_prompt:
# Check if we're in GUI mode - if so, don't show CLI prompts, just fail and let GUI callbacks handle it
gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
if gui_mode:
# In GUI mode: don't show CLI prompts, just fail so GUI can show dialog and retry
self.logger.info("GUI mode detected: skipping CLI manual steps prompt, will fail configuration to trigger GUI callback")
if status_callback:
status_callback("Manual Steam/Proton setup required - this will be handled by GUI dialog")
# Return False to trigger manual steps callback in GUI
return False
else:
# CLI mode: show the traditional CLI prompt
if status_callback:
status_callback("Please perform the manual steps in Steam (set Proton, launch shortcut, then close MO2)...")
self.logger.info("Prompting user to perform manual Steam/Proton steps and launch shortcut.")
print("\n───────────────────────────────────────────────────────────────────")
print(f"{COLOR_INFO}Manual Steps Required:{COLOR_RESET} Please follow the on-screen instructions to set Proton Experimental and launch the shortcut from Steam.")
print("───────────────────────────────────────────────────────────────────")
input(f"{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}")
self.logger.info("User confirmed completion of manual steps.")
# Step 3: Download and apply curated user.reg.modlist and system.reg.modlist
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Applying curated registry files for modlist configuration")
self.logger.info("Step 3: Downloading and applying curated user.reg.modlist and system.reg.modlist...")
try:
prefix_path_str = self.path_handler.find_compat_data(str(self.appid))
if not prefix_path_str or not os.path.isdir(prefix_path_str):
raise Exception("Could not determine Wine prefix path for this modlist. Please ensure you have launched the shortcut from Steam at least once.")
user_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/user.reg.modlist"
user_reg_dest = Path(prefix_path_str) / "user.reg"
response = requests.get(user_reg_url, verify=True)
response.raise_for_status()
with open(user_reg_dest, "wb") as f:
f.write(response.content)
self.logger.info(f"Curated user.reg.modlist downloaded and applied to {user_reg_dest}")
system_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/system.reg.modlist"
system_reg_dest = Path(prefix_path_str) / "system.reg"
response = requests.get(system_reg_url, verify=True)
response.raise_for_status()
with open(system_reg_dest, "wb") as f:
f.write(response.content)
self.logger.info(f"Curated system.reg.modlist downloaded and applied to {system_reg_dest}")
except Exception as e:
self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist: {e}")
self.logger.error(f"Failed to download or apply curated user.reg.modlist or system.reg.modlist. {e}")
return False
self.logger.info("Step 3: Curated user.reg.modlist and system.reg.modlist applied successfully.")
# Step 4: Install Wine Components
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Installing Wine components (this may take a while)")
self.logger.info("Step 4: Installing Wine components (this may take a while)...")
# Use canonical logic for all modlists/games
components = self.get_modlist_wine_components(self.game_name, self.game_var_full)
# All modlists now use their own AppID for wine components
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.")
self.logger.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, status_callback=status_callback, appid=str(target_appid) if target_appid else None)
if success:
self.logger.info("Wine component installation completed successfully")
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Wine components verified and installed successfully")
else:
self.logger.error("Wine component installation failed")
self.logger.error("Failed to install necessary Wine components.")
return False
except Exception as e:
self.logger.error(f"Wine component installation failed with exception: {e}")
self.logger.error("Failed to install necessary Wine components.")
return False
self.logger.info("Step 4: Installing Wine components... Done")
# Step 4.5: Apply universal dotnet4.x compatibility registry fixes AFTER wine components
# Apply after components to avoid overwrite
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Applying universal dotnet4.x compatibility fixes")
self.logger.info("Step 4.5: Applying universal dotnet4.x compatibility registry fixes...")
registry_success = False
try:
registry_success = self._apply_universal_dotnet_fixes()
except Exception as e:
error_msg = f"CRITICAL: Registry fixes failed - modlist may have .NET compatibility issues: {e}"
self.logger.error(error_msg)
if status_callback:
status_callback(f"{self._get_progress_timestamp()} ERROR: {error_msg}")
registry_success = False
if not registry_success:
failure_msg = "WARNING: Universal dotnet4.x registry fixes FAILED! This modlist may experience .NET Framework compatibility issues."
self.logger.error("=" * 80)
self.logger.error(failure_msg)
self.logger.error("Consider manually setting mscoree=native in winecfg if problems occur.")
self.logger.error("=" * 80)
if status_callback:
status_callback(f"{self._get_progress_timestamp()} {failure_msg}")
# Continue but user should be aware of potential issues
# Step 4.6: Enable dotfiles visibility for Wine prefix
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Enabling dotfiles visibility")
self.logger.info("Step 4.6: Enabling dotfiles visibility in Wine prefix...")
try:
if self.protontricks_handler.enable_dotfiles(self.appid):
self.logger.info("Dotfiles visibility enabled successfully")
else:
self.logger.warning("Failed to enable dotfiles visibility (non-critical, continuing)")
except Exception as e:
self.logger.warning(f"Error enabling dotfiles visibility: {e} (non-critical, continuing)")
self.logger.info("Step 4.6: Enabling dotfiles visibility... Done")
# Step 4.7: Create Wine prefix Documents directories for USVFS
# Critical for USVFS profile INI virtualization on first launch
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Creating Wine prefix Documents directories for USVFS")
self.logger.info("Step 4.7: Creating Wine prefix Documents directories for USVFS...")
try:
if self.appid and self.game_var:
# Map game_var to game_name for create_required_dirs
game_name_map = {
"skyrimspecialedition": "skyrimse",
"fallout4": "fallout4",
"falloutnv": "falloutnv",
"oblivion": "oblivion",
"enderalspecialedition": "enderalse"
}
game_name = game_name_map.get(self.game_var.lower(), None)
if game_name:
appid_str = str(self.appid)
if self.filesystem_handler.create_required_dirs(game_name, appid_str):
self.logger.info("Wine prefix Documents directories created successfully for USVFS")
else:
self.logger.warning("Failed to create Wine prefix Documents directories (non-critical, continuing)")
else:
self.logger.debug(f"Game {self.game_var} not in directory creation map, skipping")
else:
self.logger.warning("AppID or game_var not available, skipping Wine prefix Documents directory creation")
except Exception as e:
self.logger.warning(f"Error creating Wine prefix Documents directories: {e} (non-critical, continuing)")
self.logger.info("Step 4.7: Creating Wine prefix Documents directories... Done")
# Step 5: Verify ownership of Modlist directory
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Verifying modlist directory ownership")
self.logger.info("Step 5: Verifying ownership of modlist directory...")
# Convert modlist_dir string to Path object for the method
modlist_path_obj = Path(self.modlist_dir)
success, error_msg = self.filesystem_handler.verify_ownership_and_permissions(modlist_path_obj)
if not success:
self.logger.error("Ownership verification failed for modlist directory. Configuration aborted.")
print(f"\n{COLOR_ERROR}{error_msg}{COLOR_RESET}")
return False # Abort on failure
self.logger.info("Step 5: Ownership verification... Done")
# Step 6: Backup ModOrganizer.ini
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Backing up ModOrganizer.ini")
self.logger.info(f"Step 6: Backing up {self.modlist_ini}...")
modlist_ini_path_obj = Path(self.modlist_ini)
backup_path = self.filesystem_handler.backup_file(modlist_ini_path_obj)
if not backup_path:
self.logger.error("Failed to back up ModOrganizer.ini. Configuration aborted.")
self.logger.error("Failed to back up ModOrganizer.ini.")
return False # Abort on failure
self.logger.info(f"ModOrganizer.ini backed up to: {backup_path}")
self.logger.info("Step 6: Backing up ModOrganizer.ini... Done")
# Step 6.5: Handle symlinked downloads directory
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Checking for symlinked downloads directory")
self.logger.info("Step 6.5: Checking for symlinked downloads directory...")
if not self._handle_symlinked_downloads():
self.logger.warning("Warning during symlink handling (non-critical)")
self.logger.info("Step 6.5: Checking for symlinked downloads directory... Done")
# Step 7a: Detect Stock Game/Game Root path
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Detecting stock game path")
# Sets self.stock_game_path if found
if not self._detect_stock_game_path():
self.logger.error("Failed during stock game path detection.")
self.logger.error("Failed during stock game path detection.")
return False
# Step 7b: Detect Steam Library Info (Needed for Step 8)
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Detecting Steam Library info")
self.logger.info("Step 7b: Detecting Steam Library info...")
if not self._detect_steam_library_info():
self.logger.error("Failed to detect necessary Steam Library information.")
self.logger.error("Could not find Steam library information.")
return False
self.logger.info("Step 7b: Detecting Steam Library info... Done")
# Step 8: Update ModOrganizer.ini Paths (gamePath, Binary, workingDirectory)
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Updating ModOrganizer.ini paths")
self.logger.info("Step 8: Updating gamePath, Binary, and workingDirectory paths in ModOrganizer.ini...")
# Update gamePath using replace_gamepath method
modlist_dir_path_obj = Path(self.modlist_dir)
modlist_ini_path_obj = Path(self.modlist_ini)
stock_game_path_obj = Path(self.stock_game_path) if self.stock_game_path else None
# Only call replace_gamepath if we have a valid stock game path
if stock_game_path_obj:
if not self.path_handler.replace_gamepath(
modlist_ini_path=modlist_ini_path_obj,
new_game_path=stock_game_path_obj,
modlist_sdcard=self.modlist_sdcard
):
self.logger.error("Failed to update gamePath in ModOrganizer.ini. Configuration aborted.")
self.logger.error("Failed to update game path in ModOrganizer.ini.")
return False # Abort on failure
else:
self.logger.info("No stock game path found, skipping gamePath update - edit_binary_working_paths will handle all path updates.")
self.logger.info("Using unified path manipulation to avoid duplicate processing.")
# Conditionally update binary and working directory paths
# Skip for jackify-engine workflows since paths are already correct
# Exception: Always run for SD card installs to fix Z:/run/media/... to D:/... paths
# DEBUG: Add comprehensive logging to identify Steam Deck SD card path manipulation issues
engine_installed = getattr(self, 'engine_installed', False)
self.logger.debug(f"[SD_CARD_DEBUG] ModlistHandler instance: id={id(self)}")
self.logger.debug(f"[SD_CARD_DEBUG] engine_installed: {engine_installed}")
self.logger.debug(f"[SD_CARD_DEBUG] modlist_sdcard: {self.modlist_sdcard}")
self.logger.debug(f"[SD_CARD_DEBUG] steamdeck parameter passed to constructor: {getattr(self, 'steamdeck', 'NOT_SET')}")
self.logger.debug(f"[SD_CARD_DEBUG] Path manipulation condition: not {engine_installed} or {self.modlist_sdcard} = {not engine_installed or self.modlist_sdcard}")
if not getattr(self, 'engine_installed', False) or self.modlist_sdcard:
# Convert steamapps/common path to library root path
steam_libraries = None
if self.steam_library:
# self.steam_library is steamapps/common, need to go up 2 levels to get library root
steam_library_root = Path(self.steam_library).parent.parent
steam_libraries = [steam_library_root]
self.logger.debug(f"Using Steam library root: {steam_library_root}")
if not self.path_handler.edit_binary_working_paths(
modlist_ini_path=modlist_ini_path_obj,
modlist_dir_path=modlist_dir_path_obj,
modlist_sdcard=self.modlist_sdcard,
steam_libraries=steam_libraries
):
self.logger.error("Failed to update binary and working directory paths in ModOrganizer.ini. Configuration aborted.")
self.logger.error("Failed to update binary and working directory paths in ModOrganizer.ini.")
return False # Abort on failure
else:
self.logger.debug("[SD_CARD_DEBUG] Skipping path manipulation - jackify-engine already set correct paths in ModOrganizer.ini")
self.logger.debug(f"[SD_CARD_DEBUG] SKIPPED because: engine_installed={engine_installed} and modlist_sdcard={self.modlist_sdcard}")
if getattr(self, 'download_dir', None):
if self.path_handler.set_download_directory(
modlist_ini_path_obj, str(self.download_dir), self.modlist_sdcard
):
self.logger.info("Set download_directory in ModOrganizer.ini (Install flow)")
else:
self.logger.warning("Could not set download_directory in ModOrganizer.ini")
self.logger.info("Step 8: Updating ModOrganizer.ini paths... Done")
# Step 9: Update Resolution Settings (if applicable)
if hasattr(self, 'selected_resolution') and self.selected_resolution:
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Updating resolution settings")
# Ensure resolution_handler call uses correct args if needed
# Assuming it uses modlist_dir (str) and game_var_full (str)
# Construct vanilla game directory path for fallback
vanilla_game_dir = None
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 ResolutionHandler.update_ini_resolution(
modlist_dir=self.modlist_dir,
game_var=self.game_var_full,
set_res=self.selected_resolution,
vanilla_game_dir=vanilla_game_dir
):
self.logger.warning("Failed to update resolution settings in some INI files.")
self.logger.warning("Failed to update resolution settings.")
self.logger.info("Step 9: Updating resolution in INI files... Done")
else:
self.logger.info("Step 9: Skipping resolution update (no resolution selected).")
# Step 10: Create dxvk.conf (skip for special games using vanilla compatdata)
special_game_type = self.detect_special_game_type(self.modlist_dir)
self.logger.debug(f"DXVK step - modlist_dir='{self.modlist_dir}', special_game_type='{special_game_type}'")
# Force check specific files for debugging
nvse_path = Path(self.modlist_dir) / "nvse_loader.exe" if self.modlist_dir else None
enderal_path = Path(self.modlist_dir) / "Enderal Launcher.exe" if self.modlist_dir else None
self.logger.debug(f"nvse_loader.exe exists: {nvse_path.exists() if nvse_path else 'N/A'}")
self.logger.debug(f"Enderal Launcher.exe exists: {enderal_path.exists() if enderal_path else 'N/A'}")
if special_game_type:
self.logger.info(f"Step 10: Skipping dxvk.conf creation for {special_game_type.upper()} (uses vanilla compatdata)")
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Skipping dxvk.conf for {special_game_type.upper()} modlist")
else:
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Creating dxvk.conf file")
self.logger.info("Step 10: Creating dxvk.conf file...")
# Assuming create_dxvk_conf still uses string paths
# Construct vanilla game directory path for fallback
vanilla_game_dir = None
if self.steam_library and self.game_var_full:
vanilla_game_dir = str(Path(self.steam_library) / "steamapps" / "common" / self.game_var_full)
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,
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.")
self.logger.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
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Deleting incompatible MO2 plugins")
self.logger.info("Step 11a: Deleting incompatible MO2 plugins...")
# Delete FixGameRegKey.py plugin
fixgamereg_path = Path(self.modlist_dir) / "plugins" / "FixGameRegKey.py"
if fixgamereg_path.exists():
try:
fixgamereg_path.unlink()
self.logger.info("FixGameRegKey.py plugin deleted successfully.")
except Exception as e:
self.logger.warning(f"Failed to delete FixGameRegKey.py plugin: {e}")
self.logger.warning("Failed to delete FixGameRegKey.py plugin file.")
else:
self.logger.debug("FixGameRegKey.py plugin not found (this is normal).")
# Delete PageFileManager plugin directory (Linux has no PageFile)
pagefilemgr_path = Path(self.modlist_dir) / "plugins" / "PageFileManager"
if pagefilemgr_path.exists():
try:
import shutil
shutil.rmtree(pagefilemgr_path)
self.logger.info("PageFileManager plugin directory deleted successfully.")
except Exception as e:
self.logger.warning(f"Failed to delete PageFileManager plugin directory: {e}")
self.logger.warning("Failed to delete PageFileManager plugin directory.")
else:
self.logger.debug("PageFileManager plugin not found (this is normal).")
self.logger.info("Step 11a: Incompatible plugin deletion check complete.")
# Step 11b: Download Font
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Downloading required font")
prefix_path_str = self.path_handler.find_compat_data(str(self.appid))
if prefix_path_str:
prefix_path = Path(prefix_path_str)
fonts_dir = prefix_path / "pfx" / "drive_c" / "windows" / "Fonts"
font_url = "https://github.com/mrbvrz/segoe-ui-linux/raw/refs/heads/master/font/seguisym.ttf"
font_dest_path = fonts_dir / "seguisym.ttf"
# Pass quiet=True to suppress print during configuration steps
if not self.filesystem_handler.download_file(font_url, font_dest_path, quiet=True):
self.logger.warning(f"Failed to download {font_url} to {font_dest_path}")
self.logger.warning("Failed to download necessary font file (seguisym.ttf).")
# Continue anyway, not critical for all lists
else:
self.logger.info("Font downloaded successfully.")
else:
self.logger.error("Could not get WINEPREFIX path, skipping font download.")
self.logger.warning("Could not determine Wine prefix path, skipping font download.")
# Step 12: Modlist-specific steps
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Checking for modlist-specific steps")
status_callback("") # Blank line after final Prefix Configuration step
self.logger.info("Step 12: Checking for modlist-specific steps...")
# Step 13: Launch options for special games are now set during automated prefix workflow (before Steam restart)
# Avoids a second Steam restart
special_game_type = self.detect_special_game_type(self.modlist_dir)
if special_game_type:
self.logger.info(f"Step 13: Launch options for {special_game_type.upper()} were set during automated workflow")
else:
self.logger.debug("Step 13: No special launch options needed for this modlist type")
# Do not call status_callback here, the final message is handled in menu_handler
# if status_callback:
# status_callback("Configuration completed successfully!")
self.logger.info("Configuration steps completed successfully.")
# Step 14: Re-enforce Windows 10 mode after modlist-specific configurations (matches legacy script line 1333)
self._re_enforce_windows_10_mode()
return True # Return True on success
def run_modlist_configuration_phase(self, context: dict = None) -> bool:
"""
Main entry point to run the full modlist configuration sequence.
This orchestrates all the individual steps.
"""
self.logger.info(f"Starting configuration phase for modlist: {self.game_name}")
# Call the private method that contains the actual steps
# Pass along the status_callback if it was provided in the context
status_callback = context.get('status_callback') if context else None
return self._execute_configuration_steps(status_callback=status_callback)
def _prompt_or_set_resolution(self):
# If on Steam Deck, set 1280x800 automatically
if self._is_steam_deck():
self.selected_resolution = "1280x800"
self.logger.info("Steam Deck detected: setting resolution to 1280x800.")
else:
print("Do you wish to set the display resolution? (This can be changed manually later)")
response = input("Set resolution? (y/N): ").strip().lower()
if response == 'y':
while True:
user_res = input("Enter resolution (e.g., 1920x1080): ").strip()
if re.match(r'^[0-9]+x[0-9]+$', user_res):
self.selected_resolution = user_res
self.logger.info(f"User selected resolution: {user_res}")
break
else:
print("Invalid format. Please use format: 1920x1080")
else:
self.selected_resolution = None
self.logger.info("Resolution setup skipped by user.")

View File

@@ -0,0 +1,376 @@
"""Detection and discovery methods for ModlistHandler (Mixin)."""
from pathlib import Path
from typing import Dict, List, Optional
import os
import re
import logging
import subprocess
logger = logging.getLogger(__name__)
class ModlistDetectionMixin:
"""Mixin providing detection and discovery methods for ModlistHandler.
These methods are separated for code organization but require
ModlistHandler's instance attributes (self.logger, self.path_handler, etc.)
"""
def _detect_modlists_from_shortcuts(self) -> bool:
"""
Detect modlists from Steam shortcuts.vdf entries
"""
self.logger.info("Detecting modlists from Steam shortcuts")
return False
def discover_executable_shortcuts(self, executable_name: str) -> List[Dict]:
"""Discovers non-Steam shortcuts pointing to a specific executable.
Args:
executable_name: The name of the executable (e.g., "ModOrganizer.exe")
to look for in the shortcut's 'Exe' path.
Returns:
A list of dictionaries, each containing validated shortcut info:
{'name': AppName, 'appid': AppID, 'path': StartDir}
Returns an empty list if none are found or an error occurs.
"""
self.logger.info(f"Discovering non-Steam shortcuts for executable: {executable_name}")
discovered_modlists_info = []
try:
# Get shortcuts pointing to the executable from shortcuts.vdf
matching_vdf_shortcuts = self.shortcut_handler.find_shortcuts_by_exe(executable_name)
if not matching_vdf_shortcuts:
self.logger.debug(f"No shortcuts found pointing to '{executable_name}' in shortcuts.vdf.")
return []
self.logger.debug(f"Shortcuts matching executable '{executable_name}' in VDF: {matching_vdf_shortcuts}")
# Process each matching shortcut and convert signed AppID to unsigned
for vdf_shortcut in matching_vdf_shortcuts:
app_name = vdf_shortcut.get('AppName')
start_dir = vdf_shortcut.get('StartDir')
signed_appid = vdf_shortcut.get('appid')
if not app_name or not start_dir:
self.logger.warning(f"Skipping VDF shortcut due to missing AppName or StartDir: {vdf_shortcut}")
continue
if signed_appid is None:
self.logger.warning(f"Skipping VDF shortcut due to missing appid: {vdf_shortcut}")
continue
# Convert signed AppID to unsigned AppID (the format used by Steam prefixes)
if signed_appid < 0:
unsigned_appid = signed_appid + (2**32)
else:
unsigned_appid = signed_appid
# Append dictionary with all necessary info using unsigned AppID
modlist_info = {
'name': app_name,
'appid': unsigned_appid,
'path': start_dir
}
discovered_modlists_info.append(modlist_info)
self.logger.info(f"Discovered shortcut: '{app_name}' (Signed: {signed_appid} -> Unsigned: {unsigned_appid}, Path: {start_dir})")
except Exception as e:
self.logger.error(f"Error discovering executable shortcuts: {e}", exc_info=True)
return []
if not discovered_modlists_info:
self.logger.warning("No validated shortcuts found after correlation.")
return discovered_modlists_info
def _detect_game_variables(self):
"""Detect game_var and game_var_full based on ModOrganizer.ini content."""
if not self.modlist_ini or not Path(self.modlist_ini).is_file():
self.logger.error("Cannot detect game variables: ModOrganizer.ini path not set or file not found.")
self.game_var = "Unknown"
self.game_var_full = "Unknown"
return False
# Define mapping from loader executable to full game name
loader_to_game = {
"skse64_loader.exe": "Skyrim Special Edition",
"f4se_loader.exe": "Fallout 4",
"nvse_loader.exe": "Fallout New Vegas",
"obse_loader.exe": "Oblivion"
}
# Short name lookup
short_name_lookup = {
"Skyrim Special Edition": "Skyrim",
"Fallout 4": "Fallout",
"Fallout New Vegas": "FNV",
"Oblivion": "Oblivion"
}
try:
with open(self.modlist_ini, 'r', encoding='utf-8', errors='ignore') as f:
ini_content = f.read().lower()
except Exception as e:
self.logger.error(f"Error reading ModOrganizer.ini ({self.modlist_ini}): {e}")
self.game_var = "Unknown"
self.game_var_full = "Unknown"
return False
found_game = None
for loader, game_name in loader_to_game.items():
if loader in ini_content:
found_game = game_name
self.logger.info(f"Detected game type '{found_game}' based on finding '{loader}' in ModOrganizer.ini")
break
if found_game:
self.game_var_full = found_game
self.game_var = short_name_lookup.get(found_game, found_game.split()[0])
return True
else:
self.logger.warning(f"Could not detect game type from ModOrganizer.ini content. Check INI for known loaders (skse64, f4se, nvse, obse).")
self.game_var = "Unknown"
self.game_var_full = "Unknown"
return False
def _detect_proton_version(self):
"""Detect the Proton version used for the modlist prefix."""
self.logger.info(f"Detecting Proton version for AppID {self.appid}...")
self.proton_ver = "Unknown"
if not self.appid:
self.logger.error("Cannot detect Proton version without a valid AppID.")
return False
# Check config.vdf first for user-selected tool name
try:
config_vdf_path = self.path_handler.find_steam_config_vdf()
if config_vdf_path and config_vdf_path.exists():
import vdf
with open(config_vdf_path, 'r') as f:
data = vdf.load(f)
mapping = data.get('InstallConfigStore', {}).get('Software', {}).get('Valve', {}).get('Steam', {}).get('CompatToolMapping', {})
app_mapping = mapping.get(str(self.appid), {})
tool_name = app_mapping.get('name', '')
if tool_name and 'experimental' in tool_name.lower():
self.proton_ver = tool_name
self.logger.info(f"Detected Proton tool from config.vdf: {self.proton_ver}")
return True
elif tool_name:
self.logger.debug(f"Proton tool from config.vdf: {tool_name}. Checking registry for runtime version.")
else:
self.logger.debug(f"No specific Proton tool mapping found for AppID {self.appid} in config.vdf.")
else:
self.logger.debug("config.vdf not found, proceeding with registry check.")
except ImportError:
self.logger.warning("Python 'vdf' library not found. Cannot check config.vdf for Proton version. Skipping.")
except Exception as e:
self.logger.warning(f"Error reading config.vdf: {e}. Proceeding with registry check.")
# If config.vdf didn't yield 'Experimental', check prefix files
if not self.compat_data_path or not self.compat_data_path.exists():
self.logger.warning(f"Compatdata path '{self.compat_data_path}' not found or invalid for AppID {self.appid}. Cannot detect Proton version via prefix files.")
return False
# Method 1: Check system.reg
system_reg_path = self.compat_data_path / "pfx" / "system.reg"
if system_reg_path.exists():
try:
with open(system_reg_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
match = re.search(r'"SteamClientProtonVersion"="([^"]+)"\r?', content)
if match:
version_str = match.group(1).strip()
if version_str:
if "GE" in version_str.upper():
self.proton_ver = version_str
else:
self.proton_ver = f"Proton {version_str}"
self.logger.info(f"Detected Proton runtime version from system.reg: {self.proton_ver}")
return True
else:
self.logger.debug("'SteamClientProtonVersion' not found in system.reg.")
except Exception as e:
self.logger.warning(f"Error reading system.reg: {e}")
else:
self.logger.debug("system.reg not found.")
# Method 2: Check config_info
config_info_path = self.compat_data_path / "config_info"
if config_info_path.exists():
try:
with open(config_info_path, 'r') as f:
version_str = f.readline().strip()
if version_str:
if "GE" in version_str.upper():
self.proton_ver = version_str
else:
self.proton_ver = f"Proton {version_str}"
self.logger.info(f"Detected Proton runtime version from config_info: {self.proton_ver}")
return True
except Exception as e:
self.logger.warning(f"Error reading config_info: {e}")
else:
self.logger.debug("config_info file not found.")
self.logger.warning(f"Could not detect Proton version for AppID {self.appid} from prefix files.")
return False
def _detect_steam_library_info(self) -> bool:
"""Detects Steam Library path and whether it's on an SD card."""
from .path_handler import PathHandler
self.logger.debug("Detecting Steam Library path...")
steam_lib_path_str = PathHandler.find_steam_library()
if not steam_lib_path_str:
self.logger.error("PathHandler.find_steam_library() failed to find a Steam library.")
self.steam_library = None
self.basegame_sdcard = False
return False
self.steam_library = steam_lib_path_str
self.logger.info(f"Detected Steam Library: {self.steam_library}")
self.logger.debug(f"Checking if Steam Library {self.steam_library} is on SD card...")
steam_lib_path_obj = Path(self.steam_library)
self.basegame_sdcard = self.filesystem_handler.is_sd_card(steam_lib_path_obj)
self.logger.info(f"Base game library on SD card: {self.basegame_sdcard}")
return True
def _detect_stock_game_path(self):
"""Detects common 'Stock Game' or 'Game Root' directories within the modlist path."""
self.logger.info("Step 7a: Detecting Stock Game/Game Root directory...")
if not self.modlist_dir:
self.logger.error("Modlist directory not set, cannot detect stock game path.")
return False
modlist_path = Path(self.modlist_dir)
common_names = [
"Stock Game",
"Game Root",
"STOCK GAME",
"Stock Game Folder",
"Stock Folder",
"Skyrim Stock",
Path("root/Skyrim Special Edition")
]
found_path = None
for name in common_names:
potential_path = modlist_path / name
if potential_path.is_dir():
found_path = str(potential_path)
self.logger.info(f"Found potential stock game directory: {found_path}")
break
if found_path:
self.stock_game_path = found_path
return True
else:
self.stock_game_path = None
self.logger.info("No common Stock Game/Game Root directory found. Will assume vanilla game path is needed for some operations.")
return True
def _is_steam_deck(self):
"""Detect if running on Steam Deck."""
try:
if os.path.exists('/etc/os-release'):
with open('/etc/os-release') as f:
if 'steamdeck' in f.read().lower():
return True
user_services = subprocess.run(['systemctl', '--user', 'list-units', '--type=service', '--no-pager'], capture_output=True, text=True)
if 'app-steam@autostart.service' in user_services.stdout:
return True
except Exception as e:
self.logger.warning(f"Error detecting Steam Deck: {e}")
return False
def detect_special_game_type(self, modlist_dir: str) -> Optional[str]:
"""
Detect if this modlist requires vanilla compatdata instead of new prefix.
Detects special game types that need to use existing vanilla game compatdata:
- FNV: Has nvse_loader.exe
- Enderal: Has Enderal Launcher.exe
Args:
modlist_dir: Path to the modlist installation directory
Returns:
str: Game type ("fnv", "enderal") or None if not a special game
"""
if not modlist_dir:
return None
modlist_path = Path(modlist_dir)
if not modlist_path.exists() or not modlist_path.is_dir():
self.logger.debug(f"Modlist directory does not exist: {modlist_dir}")
return None
self.logger.debug(f"Checking for special game type in: {modlist_dir}")
# Check ModOrganizer.ini for indicators
try:
mo2_ini = modlist_path / "ModOrganizer.ini"
if not mo2_ini.exists():
somnium_mo2_ini = modlist_path / "files" / "ModOrganizer.ini"
if somnium_mo2_ini.exists():
mo2_ini = somnium_mo2_ini
if mo2_ini.exists():
try:
content = mo2_ini.read_text(errors='ignore').lower()
if 'nvse' in content or 'nvse_loader' in content or 'fallout new vegas' in content or 'falloutnv' in content:
self.logger.info("Detected FNV via ModOrganizer.ini markers")
return "fnv"
if any(pattern in content for pattern in ['enderal launcher', 'enderal.exe', 'enderal launcher.exe', 'enderalsteam']):
self.logger.info("Detected Enderal via ModOrganizer.ini markers")
return "enderal"
except Exception as e:
self.logger.debug(f"Failed reading ModOrganizer.ini for detection: {e}")
except Exception:
pass
# Check for FNV and Enderal launchers in common locations
candidates = [modlist_path]
try:
from .path_handler import STOCK_GAME_FOLDERS
for folder_name in STOCK_GAME_FOLDERS:
sub = modlist_path / folder_name
if sub.exists() and sub.is_dir():
candidates.append(sub)
except Exception:
pass
for base in candidates:
nvse_loader = base / "nvse_loader.exe"
if nvse_loader.exists():
self.logger.info(f"Detected FNV modlist: found nvse_loader.exe in '{base}'")
return "fnv"
enderal_launcher = base / "Enderal Launcher.exe"
if enderal_launcher.exists():
self.logger.info(f"Detected Enderal modlist: found Enderal Launcher.exe in '{base}'")
return "enderal"
# Final heuristic using game_var
try:
game_type = getattr(self, 'game_var', None)
if isinstance(game_type, str):
gt = game_type.strip().lower()
if 'fallout new vegas' in gt or gt == 'fnv':
self.logger.info("Heuristic detection: game_var indicates FNV")
return "fnv"
if 'enderal' in gt:
self.logger.info("Heuristic detection: game_var indicates Enderal")
return "enderal"
except Exception:
pass
self.logger.debug("No special game type detected - standard workflow will be used")
return None

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,527 @@
"""Configuration phase methods for ModlistInstallCLI (Mixin)."""
import logging
import os
import subprocess
import sys
import time
from pathlib import Path
from .engine_monitor import EnginePerformanceMonitor, create_stall_alert_callback
from .ui_colors import (
COLOR_PROMPT,
COLOR_RESET,
COLOR_INFO,
COLOR_ERROR,
COLOR_WARNING,
)
logger = logging.getLogger(__name__)
class ModlistInstallCLIConfigurationMixin:
"""Mixin providing configuration phase methods."""
def configuration_phase(self):
"""
Run the configuration phase: execute the Linux-native Jackify Install Engine.
"""
import subprocess
import time
import sys
from pathlib import Path
from .modlist_install_cli import get_jackify_engine_path
# UI Colors and LoggingHandler already imported at module level
print(f"\n{COLOR_PROMPT}--- Configuration Phase: Installing Modlist ---{COLOR_RESET}")
start_time = time.time()
# --- BEGIN: TEE LOGGING SETUP & LOG ROTATION ---
from jackify.shared.paths import get_jackify_logs_dir
log_dir = get_jackify_logs_dir()
log_dir.mkdir(parents=True, exist_ok=True)
workflow_log_path = log_dir / "Modlist_Install_workflow.log"
# Log rotation: keep last 3 logs, 1MB each (adjust as needed)
max_logs = 3
max_size = 1024 * 1024 # 1MB
if workflow_log_path.exists() and workflow_log_path.stat().st_size > max_size:
for i in range(max_logs, 0, -1):
prev = log_dir / f"Modlist_Install_workflow.log.{i-1}" if i > 1 else workflow_log_path
dest = log_dir / f"Modlist_Install_workflow.log.{i}"
if prev.exists():
if dest.exists():
dest.unlink()
prev.rename(dest)
workflow_log = open(workflow_log_path, 'a')
class TeeStdout:
def __init__(self, *files):
self.files = files
def write(self, data):
for f in self.files:
f.write(data)
f.flush()
def flush(self):
for f in self.files:
f.flush()
orig_stdout, orig_stderr = sys.stdout, sys.stderr
sys.stdout = TeeStdout(sys.stdout, workflow_log)
sys.stderr = TeeStdout(sys.stderr, workflow_log)
# --- END: TEE LOGGING SETUP & LOG ROTATION ---
try:
# --- Process Paths from context ---
install_dir_context = self.context['install_dir']
if isinstance(install_dir_context, tuple):
actual_install_path = Path(install_dir_context[0])
if install_dir_context[1]: # Second element is True if creation was intended
self.logger.info(f"Creating install directory as it was marked for creation: {actual_install_path}")
actual_install_path.mkdir(parents=True, exist_ok=True)
else: # Should be a Path object or string already
actual_install_path = Path(install_dir_context)
install_dir_str = str(actual_install_path)
self.logger.debug(f"Processed install directory for engine: {install_dir_str}")
download_dir_context = self.context['download_dir']
if isinstance(download_dir_context, tuple):
actual_download_path = Path(download_dir_context[0])
if download_dir_context[1]: # Second element is True if creation was intended
self.logger.info(f"Creating download directory as it was marked for creation: {actual_download_path}")
actual_download_path.mkdir(parents=True, exist_ok=True)
else: # Should be a Path object or string already
actual_download_path = Path(download_dir_context)
download_dir_str = str(actual_download_path)
self.logger.debug(f"Processed download directory for engine: {download_dir_str}")
# --- End Process Paths ---
modlist_arg = self.context.get('modlist_value') or self.context.get('machineid')
machineid = self.context.get('machineid')
# CRITICAL: Re-check authentication right before launching engine
# Use current auth state, not stale cached context
# (e.g., if user revoked OAuth after context was created)
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
current_api_key, current_oauth_info = auth_service.get_auth_for_engine()
# Use current auth state, fallback to context values only if current check failed
api_key = current_api_key or self.context.get('nexus_api_key')
oauth_info = current_oauth_info or self.context.get('nexus_oauth_info')
# Path to the engine binary
engine_path = get_jackify_engine_path()
engine_dir = os.path.dirname(engine_path)
if not os.path.isfile(engine_path) or not os.access(engine_path, os.X_OK):
print(f"{COLOR_ERROR}Jackify Install Engine not found or not executable at: {engine_path}{COLOR_RESET}")
return
# --- Patch for GUI/auto: always set modlist_source to 'identifier' if not set, and ensure modlist_value is present ---
if os.environ.get('JACKIFY_GUI_MODE') == '1':
if not self.context.get('modlist_source'):
self.context['modlist_source'] = 'identifier'
if not self.context.get('modlist_value'):
self.logger.error("modlist_value is missing in context for GUI workflow!")
return
# --- End Patch ---
# Build command
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()
debug_mode = config_handler.get('debug_mode', False)
if debug_mode:
cmd.append('--debug')
self.logger.info("Debug mode enabled in config - passing --debug 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')
# Check if there's a cached .wabbajack file for this modlist
cached_wabbajack_path = None
if machineid:
# Convert machineid to filename (e.g., "Tuxborn/Tuxborn" -> "Tuxborn.wabbajack")
modlist_name = machineid.split('/')[-1] if '/' in machineid else machineid
from jackify.shared.paths import get_jackify_downloads_dir
cached_wabbajack_path = get_jackify_downloads_dir() / f"{modlist_name}.wabbajack"
self.logger.debug(f"Checking for cached .wabbajack file: {cached_wabbajack_path}")
if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value):
cmd += ['-w', modlist_value]
self.logger.info(f"Using local .wabbajack file: {modlist_value}")
elif cached_wabbajack_path and os.path.isfile(cached_wabbajack_path):
cmd += ['-w', cached_wabbajack_path]
self.logger.info(f"Using cached .wabbajack file: {cached_wabbajack_path}")
elif modlist_value:
cmd += ['-m', modlist_value]
self.logger.info(f"Using modlist identifier: {modlist_value}")
elif machineid:
cmd += ['-m', machineid]
self.logger.info(f"Using machineid: {machineid}")
cmd += ['-o', install_dir_str, '-d', download_dir_str]
# Store original environment values to restore later
original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
}
try:
# Temporarily modify current process's environment
# Prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY (legacy)
if oauth_info:
os.environ['NEXUS_OAUTH_INFO'] = oauth_info
# CRITICAL: Set client_id so engine can refresh tokens with correct client_id
# Engine's RefreshToken method reads this to use our "jackify" client_id instead of hardcoded "wabbajack"
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
self.logger.debug(f"Set NEXUS_OAUTH_INFO and NEXUS_OAUTH_CLIENT_ID={NexusOAuthService.CLIENT_ID} for engine (supports auto-refresh)")
# Also set NEXUS_API_KEY for backward compatibility
if api_key:
os.environ['NEXUS_API_KEY'] = api_key
elif api_key:
# No OAuth info, use API key only (no auto-refresh support)
os.environ['NEXUS_API_KEY'] = api_key
self.logger.debug(f"Set NEXUS_API_KEY for engine (no auto-refresh)")
else:
# No auth available, clear any inherited values
if 'NEXUS_API_KEY' in os.environ:
del os.environ['NEXUS_API_KEY']
if 'NEXUS_OAUTH_INFO' in os.environ:
del os.environ['NEXUS_OAUTH_INFO']
if 'NEXUS_OAUTH_CLIENT_ID' in os.environ:
del os.environ['NEXUS_OAUTH_CLIENT_ID']
self.logger.debug(f"No Nexus auth available, cleared inherited env vars")
os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1"
self.logger.debug(f"Temporarily set os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = '1' for engine call.")
self.logger.info("Environment prepared for jackify-engine install process by modifying os.environ.")
self.logger.debug(f"NEXUS_API_KEY in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_API_KEY') else '[NOT SET]'}")
self.logger.debug(f"NEXUS_OAUTH_INFO in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_OAUTH_INFO') else '[NOT SET]'}")
pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd])
print(f"{COLOR_INFO}Launching Jackify Install Engine with command:{COLOR_RESET} {pretty_cmd}")
# Temporarily increase file descriptor limit for engine process
from jackify.backend.handlers.subprocess_utils import increase_file_descriptor_limit
success, old_limit, new_limit, message = increase_file_descriptor_limit()
if success:
self.logger.debug(f"File descriptor limit: {message}")
else:
self.logger.warning(f"File descriptor limit: {message}")
# 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
if debug_mode:
# More aggressive monitoring in debug mode
performance_monitor = EnginePerformanceMonitor(
logger=self.logger,
stall_threshold=5.0, # CPU below 5% is considered stalled
stall_duration=60.0, # 1 minute of low CPU = stall (faster detection)
sample_interval=5.0 # Check every 5 seconds (more frequent)
)
# Add debug callback for detailed metrics
from .engine_monitor import create_debug_callback
performance_monitor.add_callback(create_debug_callback(self.logger))
self.logger.info("Enhanced performance monitoring enabled for debug mode")
else:
# Standard monitoring
performance_monitor = EnginePerformanceMonitor(
logger=self.logger,
stall_threshold=5.0, # CPU below 5% is considered stalled
stall_duration=120.0, # 2 minutes of low CPU = stall
sample_interval=10.0 # Check every 10 seconds
)
# Add callback to alert about performance issues
def stall_alert(message: str):
print(f"\nWarning: {message}")
print("If the process appears stuck, you may need to restart it.")
if debug_mode:
print("Debug mode: Use 'python -m jackify.backend.handlers.diagnostic_helper' for detailed analysis")
performance_monitor.add_callback(create_stall_alert_callback(self.logger, stall_alert))
# Start monitoring
monitoring_started = performance_monitor.start_monitoring(proc.pid)
if monitoring_started:
self.logger.info(f"Performance monitoring started for engine PID {proc.pid}")
else:
self.logger.warning("Failed to start performance monitoring")
try:
# Read output in binary mode to properly handle carriage returns
buffer = b''
last_progress_time = time.time()
while True:
chunk = proc.stdout.read(1)
if not chunk:
break
buffer += chunk
# Process complete lines or carriage return updates
if chunk == b'\n':
# Complete line - decode and print
line = buffer.decode('utf-8', errors='replace')
# Filter FILE_PROGRESS spam but keep the status line before it
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
# Skip this line entirely if it's only FILE_PROGRESS
buffer = b''
last_progress_time = time.time()
continue
# Enhance Nexus download errors with modlist context
enhanced_line = self._enhance_nexus_error(line)
print(enhanced_line, end='')
buffer = b''
last_progress_time = time.time()
elif chunk == b'\r':
# Carriage return - decode and print without newline
line = buffer.decode('utf-8', errors='replace')
# Filter FILE_PROGRESS spam but keep the status line before it
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
# Skip this line entirely if it's only FILE_PROGRESS
buffer = b''
last_progress_time = time.time()
continue
# Enhance Nexus download errors with modlist context
enhanced_line = self._enhance_nexus_error(line)
print(enhanced_line, end='')
sys.stdout.flush()
buffer = b''
last_progress_time = time.time()
# Check for timeout (no output for too long)
current_time = time.time()
if current_time - last_progress_time > 300: # 5 minutes no output
self.logger.warning("No output from engine for 5 minutes - possible stall")
last_progress_time = current_time # Reset to avoid spam
# Print any remaining buffer content
if buffer:
line = buffer.decode('utf-8', errors='replace')
print(line, end='')
proc.wait()
finally:
# Stop performance monitoring and get summary
if monitoring_started:
performance_monitor.stop_monitoring()
summary = performance_monitor.get_metrics_summary()
if summary:
self.logger.info(f"Engine Performance Summary: "
f"Duration: {summary.get('monitoring_duration', 0):.1f}s, "
f"Avg CPU: {summary.get('avg_cpu_percent', 0):.1f}%, "
f"Max Memory: {summary.get('max_memory_mb', 0):.1f}MB, "
f"Stalls: {summary.get('stall_percentage', 0):.1f}%")
# Log detailed summary for debugging
self.logger.debug(f"Detailed performance summary: {summary}")
if proc.returncode != 0:
print(f"{COLOR_ERROR}Jackify Install Engine exited with code {proc.returncode}.{COLOR_RESET}")
self.logger.error(f"Engine exited with code {proc.returncode}.")
return # Configuration phase failed
self.logger.info(f"Engine completed with code {proc.returncode}.")
except Exception as e:
print(f"{COLOR_ERROR}Error running Jackify Install Engine: {e}{COLOR_RESET}\n")
self.logger.error(f"Exception running engine: {e}", exc_info=True)
return # Configuration phase failed
finally:
# Restore original environment state
for key, original_value in original_env_values.items():
current_value_in_os_environ = os.environ.get(key) # Value after Popen and before our restoration for this key
# Determine display values for logging, redacting NEXUS_API_KEY
display_original_value = f"'[REDACTED]'" if key == 'NEXUS_API_KEY' else f"'{original_value}'"
# display_current_value_before_restore = f"'[REDACTED]'" if key == 'NEXUS_API_KEY' else f"'{current_value_in_os_environ}'"
if original_value is not None:
# Original value existed. We must restore it.
if current_value_in_os_environ != original_value:
os.environ[key] = original_value
self.logger.debug(f"Restored os.environ['{key}'] to its original value: {display_original_value}.")
else:
# If current value is already the original, ensure it's correctly set (os.environ[key] = original_value is harmless)
os.environ[key] = original_value # Ensure it is set
self.logger.debug(f"os.environ['{key}'] ('{display_original_value}') matched original value. Ensured restoration.")
else:
# Original value was None (key was not in os.environ initially).
if key in os.environ: # If it's in os.environ now, it means we must have set it or it was set by other means.
self.logger.debug(f"Original os.environ['{key}'] was not set. Removing current value ('{'[REDACTED]' if os.environ.get(key) and key == 'NEXUS_API_KEY' else os.environ.get(key)}') that was set for the call.")
del os.environ[key]
# If original_value was None and key is not in os.environ now, nothing to do.
except Exception as e:
print(f"{COLOR_ERROR}Error during Tuxborn installation workflow: {e}{COLOR_RESET}\n")
self.logger.error(f"Exception in Tuxborn workflow: {e}", exc_info=True)
return
finally:
# --- BEGIN: RESTORE STDOUT/STDERR ---
sys.stdout = orig_stdout
sys.stderr = orig_stderr
workflow_log.close()
# --- END: RESTORE STDOUT/STDERR ---
elapsed = int(time.time() - start_time)
print(f"\nElapsed time: {elapsed//3600:02d}:{(elapsed%3600)//60:02d}:{elapsed%60:02d} (hh:mm:ss)\n")
print(f"{COLOR_INFO}Your modlist has been installed to: {install_dir_str}{COLOR_RESET}\n")
if self.context.get('machineid') != 'Tuxborn/Tuxborn':
print(f"{COLOR_WARNING}Only Skyrim, Fallout 4, Fallout New Vegas, Oblivion, Starfield, and Oblivion Remastered modlists are compatible with Jackify's post-install configuration. Any modlist can be downloaded/installed, but only these games are supported for automated configuration.{COLOR_RESET}")
# After install, use self.context['modlist_game'] to determine if configuration should be offered
# After install, detect game type from ModOrganizer.ini
modorganizer_ini = os.path.join(install_dir_str, "ModOrganizer.ini")
detected_game = None
if os.path.isfile(modorganizer_ini):
from .modlist_handler import ModlistHandler
handler = ModlistHandler({}, steamdeck=self.steamdeck)
handler.modlist_ini = modorganizer_ini
handler.modlist_dir = install_dir_str
if handler._detect_game_variables():
detected_game = handler.game_var_full
supported_games = ["Skyrim Special Edition", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal"]
is_tuxborn = self.context.get('machineid') == 'Tuxborn/Tuxborn'
if (detected_game in supported_games) or is_tuxborn:
shortcut_name = self.context.get('modlist_name')
if is_tuxborn and not shortcut_name:
self.logger.warning("Tuxborn is true, but shortcut_name (modlist_name in context) is missing. Defaulting to 'Tuxborn Automatic Installer'")
shortcut_name = "Tuxborn Automatic Installer" # Provide a fallback default
elif not shortcut_name: # For non-Tuxborn, prompt if missing
print("\n" + "-" * 28)
print(f"{COLOR_PROMPT}Please provide a name for the Steam shortcut for '{self.context.get('modlist_name', 'this modlist')}'.{COLOR_RESET}")
raw_shortcut_name = input(f"{COLOR_PROMPT}Steam Shortcut Name (or 'q' to cancel): {COLOR_RESET} ").strip()
if raw_shortcut_name.lower() == 'q' or not raw_shortcut_name:
return
shortcut_name = raw_shortcut_name
# Check if GUI mode to skip interactive prompts
is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
if not is_gui_mode:
# Prompt user if they want to configure Steam shortcut now
print("\n" + "-" * 28)
print(f"{COLOR_PROMPT}Would you like to add '{shortcut_name}' to Steam and configure it now?{COLOR_RESET}")
configure_choice = input(f"{COLOR_PROMPT}Configure now? (Y/n): {COLOR_RESET}").strip().lower()
if configure_choice == 'n':
print(f"{COLOR_INFO}Skipping Steam configuration. You can configure it later using 'Configure New Modlist'.{COLOR_RESET}")
return
# Proceed with Steam configuration
self.logger.info(f"Starting Steam configuration for '{shortcut_name}'")
# Step 1: Create Steam shortcut first
mo2_exe_path = os.path.join(install_dir_str, 'ModOrganizer.exe')
# Use the working shortcut creation process from legacy code
from .shortcut_handler import ShortcutHandler
shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck, verbose=False)
# Create nxmhandler.ini to suppress NXM popup
shortcut_handler.write_nxmhandler_ini(install_dir_str, mo2_exe_path)
# Create shortcut with working NativeSteamService
from ..services.native_steam_service import NativeSteamService
steam_service = NativeSteamService()
success, app_id = steam_service.create_shortcut_with_proton(
app_name=shortcut_name,
exe_path=mo2_exe_path,
start_dir=os.path.dirname(mo2_exe_path),
launch_options="%command%",
tags=["Jackify"],
proton_version="proton_experimental"
)
if not success or not app_id:
self.logger.error("Failed to create Steam shortcut")
print(f"{COLOR_ERROR}Failed to create Steam shortcut. Check logs for details.{COLOR_RESET}")
return
# Step 2: Handle Steam restart and manual steps (if not in GUI mode)
if not is_gui_mode:
print(f"\n{COLOR_INFO}Steam shortcut created successfully!{COLOR_RESET}")
print("Steam needs to restart to detect the new shortcut.")
restart_choice = input("\nRestart Steam automatically now? (Y/n): ").strip().lower()
if restart_choice == 'n':
print("\nPlease restart Steam manually and complete the Proton setup steps.")
print("You can configure this modlist later using 'Configure Existing Modlist'.")
return
# Restart Steam
print("\nRestarting Steam...")
if shortcut_handler.secure_steam_restart():
print(f"{COLOR_INFO}Steam restarted successfully.{COLOR_RESET}")
# Display manual Proton steps
from .menu_handler import ModlistMenuHandler
from .config_handler import ConfigHandler
config_handler = ConfigHandler()
menu_handler = ModlistMenuHandler(config_handler)
menu_handler._display_manual_proton_steps(shortcut_name)
input(f"\n{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}")
# Get the updated AppID after launch
new_app_id = shortcut_handler.get_appid_for_shortcut(shortcut_name, mo2_exe_path)
if new_app_id and new_app_id.isdigit() and int(new_app_id) > 0:
app_id = new_app_id
else:
print(f"{COLOR_ERROR}Could not find valid AppID after launch. Configuration may not work properly.{COLOR_RESET}")
else:
print(f"{COLOR_ERROR}Steam restart failed. Please restart manually and configure later.{COLOR_RESET}")
return
# Step 3: Build configuration context with the AppID
config_context = {
'name': shortcut_name,
'appid': app_id,
'path': install_dir_str,
'mo2_exe_path': mo2_exe_path,
'resolution': self.context.get('resolution'),
'skip_confirmation': is_gui_mode,
'manual_steps_completed': not is_gui_mode # True if we did manual steps above
}
# Step 4: Use ModlistMenuHandler to run the complete configuration
from .menu_handler import ModlistMenuHandler
from .config_handler import ConfigHandler
config_handler = ConfigHandler()
modlist_menu = ModlistMenuHandler(config_handler)
self.logger.info("Running post-installation configuration phase")
configuration_success = modlist_menu.run_modlist_configuration_phase(config_context)
if configuration_success:
self.logger.info("Post-installation configuration completed successfully")
# Check for TTW integration eligibility
self._check_and_prompt_ttw_integration(install_dir_str, detected_game, shortcut_name)
else:
self.logger.warning("Post-installation configuration had issues")
else:
# Game not supported for automated configuration
print(f"{COLOR_INFO}Modlist installation complete.{COLOR_RESET}")
if detected_game:
print(f"{COLOR_WARNING}Detected game '{detected_game}' is not supported for automated Steam configuration.{COLOR_RESET}")
else:
print(f"{COLOR_WARNING}Could not detect game type from ModOrganizer.ini for automated configuration.{COLOR_RESET}")
print(f"{COLOR_INFO}You may need to manually configure the modlist for Steam/Proton.{COLOR_RESET}")

View File

@@ -0,0 +1,451 @@
"""Discovery phase methods for ModlistInstallCLI (Mixin)."""
import logging
import os
import subprocess
from pathlib import Path
from typing import Optional, Dict
from .config_handler import ConfigHandler
from .ui_colors import (
COLOR_PROMPT,
COLOR_RESET,
COLOR_INFO,
COLOR_ERROR,
COLOR_SUCCESS,
COLOR_WARNING,
COLOR_SELECTION,
)
logger = logging.getLogger(__name__)
class ModlistInstallCLIDiscoveryMixin:
"""Mixin providing discovery phase methods."""
def run_discovery_phase(self, context_override=None) -> Optional[Dict]:
"""
Run the discovery phase: prompt for all required info, and validate inputs.
Returns a context dict with all collected info, or None if cancelled.
Accepts context_override for pre-filled values (e.g., for Tuxborn/machineid flow).
"""
self.logger.info("Starting modlist discovery phase (restored logic).")
from .modlist_install_cli import get_jackify_engine_path
print(f"\n{COLOR_PROMPT}--- Wabbajack Modlist Install: Discovery Phase ---{COLOR_RESET}")
if context_override:
self.context.update(context_override)
if 'resolution' in context_override:
self.context['resolution'] = context_override['resolution']
else:
self.context = {}
is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
# Only require game_type for non-Tuxborn workflows
if self.context.get('machineid'):
required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key']
else:
required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key', 'game_type']
has_modlist = self.context.get('modlist_value') or self.context.get('machineid')
missing = [k for k in required_keys if not self.context.get(k)]
if is_gui_mode:
if missing or not has_modlist:
self.logger.error(f"Missing required arguments for GUI workflow: {', '.join(missing)}")
if not has_modlist:
self.logger.error("Missing modlist_value or machineid for GUI workflow.")
self.logger.error("This workflow must be fully non-interactive. Please report this as a bug if you see this message.")
return None
self.logger.info("All required context present in GUI mode, skipping prompts.")
return self.context
# Get engine path using the helper
engine_executable = get_jackify_engine_path()
self.logger.debug(f"Engine executable path: {engine_executable}")
if not os.path.exists(engine_executable):
print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_RESET}")
print(f"{COLOR_INFO}Expected: {engine_executable}{COLOR_RESET}")
return None
engine_dir = os.path.dirname(engine_executable)
# 1. Prompt for modlist source (unless using machineid from context_override)
if 'machineid' not in self.context:
print("\n" + "-" * 28) # Separator
print(f"{COLOR_PROMPT}How would you like to select your modlist?{COLOR_RESET}")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Select from a list of available modlists")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Provide the path to a .wabbajack file on disk")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Cancel and return to previous menu")
source_choice = input(f"{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
self.logger.debug(f"User selected modlist source option: {source_choice}")
if source_choice == '1':
self.context['modlist_source_type'] = 'online_list'
print(f"\n{COLOR_INFO}Fetching available modlists... This may take a moment.{COLOR_RESET}")
try:
env = os.environ.copy()
env["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1"
self.logger.info("Setting DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 for jackify-engine process.")
# Use the engine path from the helper function, but the command structure from restored.
engine_executable_path_for_subprocess = get_jackify_engine_path()
command = [engine_executable_path_for_subprocess, 'list-modlists', '--show-all-sizes', '--show-machine-url']
self.logger.info(f"Executing command: {' '.join(command)} in CWD: {engine_dir}")
# check=True as in restored logic
result = subprocess.run(
command,
capture_output=True, text=True, check=True,
env=env, cwd=engine_dir
)
# self.logger.debug(f"Engine stdout (raw):\n{result.stdout}") # COMMENTED OUT - too verbose
lines = result.stdout.splitlines()
# Parse new format: [STATUS] Modlist Name - Game - Download|Install|Total - MachineURL
# STATUS indicators: [DOWN], [NSFW], or both [DOWN] [NSFW]
raw_modlists_from_engine = []
for line in lines:
line = line.strip()
if not line or line.startswith('Loading') or line.startswith('Loaded'):
continue
# Extract status indicators
status_down = '[DOWN]' in line
status_nsfw = '[NSFW]' in line
# Remove status indicators to get clean line
clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip()
# Split on ' - ' to get: [Modlist Name, Game, Sizes, MachineURL]
parts = clean_line.split(' - ')
if len(parts) != 4:
continue # Skip malformed lines
modlist_name = parts[0].strip()
game_name = parts[1].strip()
sizes_str = parts[2].strip()
machine_url = parts[3].strip()
# Parse sizes: "Download|Install|Total" (e.g., "203GB|130GB|333GB")
size_parts = sizes_str.split('|')
if len(size_parts) != 3:
continue # Skip if sizes don't match expected format
download_size = size_parts[0].strip()
install_size = size_parts[1].strip()
total_size = size_parts[2].strip()
# Skip if any required data is missing
if not modlist_name or not game_name or not machine_url:
continue
raw_modlists_from_engine.append({
'id': modlist_name, # Use modlist name as ID for compatibility
'name': modlist_name,
'game': game_name,
'download_size': download_size,
'install_size': install_size,
'total_size': total_size,
'machine_url': machine_url, # Store machine URL for installation
'status_down': status_down,
'status_nsfw': status_nsfw
})
self.logger.info(f"Scraped {len(raw_modlists_from_engine)} modlists after revised regex and filtering logic.")
if not raw_modlists_from_engine:
print(f"{COLOR_WARNING}No modlists found after applying revised regex and filtering logic.{COLOR_RESET}")
return None
# EXACT game_type_map and grouping logic from restored file
game_type_map = {
'1': ('Skyrim', ['Skyrim', 'Skyrim Special Edition']),
'2': ('Fallout 4', ['Fallout 4']),
'3': ('Fallout New Vegas', ['Fallout New Vegas']),
'4': ('Oblivion', ['Oblivion']),
'5': ('Other Games', None) # Using None as in restored for keyword list
}
grouped_modlists = {k: [] for k in game_type_map}
for m_info in raw_modlists_from_engine: # m_info is like {'id': ..., 'game': ...}
found_category = False
for cat_key, (cat_label, cat_keywords) in game_type_map.items():
if cat_key == '5': # Skip 'Other Games' for direct matching initially
continue
if cat_keywords: # Ensure there are keywords to check (handles 'Other Games' with None)
for keyword in cat_keywords:
if keyword.lower() in m_info['game'].lower():
grouped_modlists[cat_key].append(m_info)
found_category = True
break # Found category for this modlist
if found_category:
break # Move to next modlist
if not found_category:
grouped_modlists['5'].append(m_info) # Add to 'Other Games'
selected_modlist_info = None # Will store {'id': ..., 'game': ...}
while not selected_modlist_info:
print(f"\n{COLOR_PROMPT}Select a game category:{COLOR_RESET}")
category_display_map = {} # Maps displayed number to actual game_type_map key
display_idx = 1
# Iterate in a defined order for consistent menu
for cat_key_ordered in ['1','2','3','4','5']:
if cat_key_ordered in grouped_modlists and grouped_modlists[cat_key_ordered]: # Only show if non-empty
cat_label = game_type_map[cat_key_ordered][0]
print(f" {COLOR_SELECTION}{display_idx}.{COLOR_RESET} {cat_label} ({len(grouped_modlists[cat_key_ordered])} modlists)")
category_display_map[str(display_idx)] = cat_key_ordered
display_idx += 1
if display_idx == 1: # No categories had any modlists
print(f"{COLOR_WARNING}No modlists found to display after grouping. Engine output might be empty or filtered entirely.{COLOR_RESET}")
return None
print(f" {COLOR_SELECTION}0.{COLOR_RESET} Cancel")
game_cat_choice = input(f"{COLOR_PROMPT}Enter selection: {COLOR_RESET}").strip()
if game_cat_choice == '0':
self.logger.info("User cancelled game category selection.")
return None
actual_cat_key = category_display_map.get(game_cat_choice)
if not actual_cat_key:
print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}")
continue
# modlist_group_for_game is a list of dicts like {'id': ..., 'game': ...}
modlist_group_for_game = sorted(grouped_modlists[actual_cat_key], key=lambda x: x['id'].lower())
print(f"\n{COLOR_SUCCESS}Available Modlists for {game_type_map[actual_cat_key][0]}:{COLOR_RESET}")
for idx, m_detail in enumerate(modlist_group_for_game, 1):
if actual_cat_key == '5': # 'Other Games' category
print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail['id']} ({m_detail['game']})")
else:
print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail['id']}")
print(f" {COLOR_SELECTION}0.{COLOR_RESET} Back to game categories")
while True:
mod_choice_idx_str = input(f"{COLOR_PROMPT}Select modlist (or 0): {COLOR_RESET}").strip()
if mod_choice_idx_str == '0':
break
if mod_choice_idx_str.isdigit():
mod_idx = int(mod_choice_idx_str) - 1
if 0 <= mod_idx < len(modlist_group_for_game):
selected_modlist_info = modlist_group_for_game[mod_idx]
self.context['modlist_source'] = 'identifier'
# Use machine_url for installation, display name for suggestions
self.context['modlist_value'] = selected_modlist_info.get('machine_url', selected_modlist_info['id'])
self.context['modlist_game'] = selected_modlist_info['game']
self.context['modlist_name_suggestion'] = selected_modlist_info['id'].split('/')[-1]
self.logger.info(f"User selected online modlist: {selected_modlist_info}")
break
else:
print(f"{COLOR_ERROR}Invalid modlist number.{COLOR_RESET}")
else:
print(f"{COLOR_ERROR}Invalid input. Please enter a number.{COLOR_RESET}")
if selected_modlist_info:
break
except subprocess.CalledProcessError as e:
self.logger.error(f"list-modlists failed. Code: {e.returncode}")
if e.stdout: self.logger.error(f"Engine stdout:\n{e.stdout}")
if e.stderr: self.logger.error(f"Engine stderr:\n{e.stderr}")
print(f"{COLOR_ERROR}Failed to fetch modlist list. Engine error (Code: {e.returncode}).{COLOR_RESET}")
return None
except FileNotFoundError:
self.logger.error(f"Engine not found: {engine_executable_path_for_subprocess}")
print(f"{COLOR_ERROR}Critical error: jackify-install-engine not found.{COLOR_RESET}")
return None
except Exception as e:
self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True)
print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_RESET}")
return None
elif source_choice == '2':
self.context['modlist_source_type'] = 'local_file'
print(f"\n{COLOR_PROMPT}Please provide the path to your .wabbajack file (tab-completion supported).{COLOR_RESET}")
modlist_path = self.menu_handler.get_existing_file_path(
prompt_message="Enter the path to your .wabbajack file (or 'q' to cancel):",
extension_filter=".wabbajack", # Ensure this is the exact filter used by the method
no_header=True # To avoid re-printing a header if get_existing_file_path has one
)
if modlist_path is None: # Assumes get_existing_file_path returns None on cancel/'q'
self.logger.info("User cancelled .wabbajack file selection.")
print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}")
return None
self.context['modlist_source'] = 'path' # For install command
self.context['modlist_value'] = str(modlist_path)
# Suggest a name based on the file
self.context['modlist_name_suggestion'] = Path(modlist_path).stem
self.logger.info(f"User selected local .wabbajack file: {modlist_path}")
elif source_choice == '0':
self.logger.info("User cancelled modlist source selection.")
print(f"{COLOR_INFO}Returning to previous menu.{COLOR_RESET}")
return None
else:
self.logger.warning(f"Invalid modlist source choice: {source_choice}")
print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}")
return self.run_discovery_phase() # Re-prompt
# --- Prompts for install_dir, download_dir, modlist_name, api_key ---
# It will use self.context['modlist_name_suggestion'] if available.
# 2. Prompt for modlist name (skip if 'modlist_name' already in context from override)
if 'modlist_name' not in self.context or not self.context['modlist_name']:
default_name = self.context.get('modlist_name_suggestion', 'MyModlist')
print("\n" + "-" * 28)
print(f"{COLOR_PROMPT}Enter a name for this modlist installation in Steam.{COLOR_RESET}")
print(f"{COLOR_INFO}(This will be the shortcut name. Default: {default_name}){COLOR_RESET}")
modlist_name_input = input(f"{COLOR_PROMPT}Modlist Name (or 'q' to cancel): {COLOR_RESET}").strip()
if not modlist_name_input: # User hit enter for default
modlist_name = default_name
elif modlist_name_input.lower() == 'q':
self.logger.info("User cancelled at modlist name prompt.")
return None
else:
modlist_name = modlist_name_input
self.context['modlist_name'] = modlist_name
self.logger.debug(f"Modlist name set to: {self.context['modlist_name']}")
# 3. Prompt for install directory
if 'install_dir' not in self.context:
# Use configurable base directory
config_handler = ConfigHandler()
base_install_dir = Path(config_handler.get_modlist_install_base_dir())
default_install_dir = base_install_dir / self.context['modlist_name']
print("\n" + "-" * 28)
print(f"{COLOR_PROMPT}Enter the main installation directory for '{self.context['modlist_name']}'.{COLOR_RESET}")
print(f"{COLOR_INFO}(Default: {default_install_dir}){COLOR_RESET}")
install_dir_path = self.menu_handler.get_directory_path(
prompt_message=f"{COLOR_PROMPT}Install directory (or 'q' to cancel, Enter for default): {COLOR_RESET}",
default_path=default_install_dir,
create_if_missing=True,
no_header=True
)
if install_dir_path is None:
self.logger.info("User cancelled at install directory prompt.")
return None
self.context['install_dir'] = install_dir_path
self.logger.debug(f"Install directory context set to: {self.context['install_dir']}")
# 4. Prompt for download directory
if 'download_dir' not in self.context:
# Use configurable base directory for downloads
config_handler = ConfigHandler()
base_download_dir = Path(config_handler.get_modlist_downloads_base_dir())
default_download_dir = base_download_dir / self.context['modlist_name']
print("\n" + "-" * 28)
print(f"{COLOR_PROMPT}Enter the downloads directory for modlist archives.{COLOR_RESET}")
print(f"{COLOR_INFO}(Default: {default_download_dir}){COLOR_RESET}")
download_dir_path = self.menu_handler.get_directory_path(
prompt_message=f"{COLOR_PROMPT}Download directory (or 'q' to cancel, Enter for default): {COLOR_RESET}",
default_path=default_download_dir,
create_if_missing=True,
no_header=True
)
if download_dir_path is None:
self.logger.info("User cancelled at download directory prompt.")
return None
self.context['download_dir'] = download_dir_path
self.logger.debug(f"Download directory context set to: {self.context['download_dir']}")
# 5. Get Nexus authentication (OAuth or API key)
if 'nexus_api_key' not in self.context:
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 and OAuth state for engine auto-refresh
api_key, oauth_info = auth_service.get_auth_for_engine()
if api_key:
self.context['nexus_api_key'] = api_key
self.context['nexus_oauth_info'] = oauth_info # For engine auto-refresh
else:
# 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, oauth_info = auth_service.get_auth_for_engine()
if api_key:
self.context['nexus_api_key'] = api_key
self.context['nexus_oauth_info'] = oauth_info # For engine auto-refresh
else:
print(f"{COLOR_ERROR}Failed to retrieve auth token after authorization.{COLOR_RESET}")
return None
else:
print(f"\n{COLOR_ERROR}OAuth authorization failed.{COLOR_RESET}")
return None
else:
# 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
if self.context.get('skip_confirmation'):
confirm = 'y'
else:
confirm = input(f"{COLOR_PROMPT}Proceed with installation using these settings? (y/N): {COLOR_RESET}").strip().lower()
if confirm != 'y':
self.logger.info("User cancelled at final confirmation.")
print(f"{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}")
return None
self.logger.info("Discovery phase complete.") # Log completion first
# Create a copy of the context for logging, so we don't alter the original
context_for_logging = self.context.copy()
if 'nexus_api_key' in context_for_logging and context_for_logging['nexus_api_key'] is not None:
context_for_logging['nexus_api_key'] = "[REDACTED]" # Redact the API key for logging
self.logger.info(f"Context: {context_for_logging}") # Log the redacted context
return self.context

View File

@@ -0,0 +1,144 @@
"""Nexus and engine methods for ModlistInstallCLI (Mixin)."""
import logging
import os
import re
import subprocess
from pathlib import Path
from typing import Optional
from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_RESET
logger = logging.getLogger(__name__)
class ModlistInstallCLINexusMixin:
"""Mixin providing Nexus API and engine methods."""
def _get_nexus_api_key(self) -> Optional[str]:
return self.context.get('nexus_api_key')
def get_all_modlists_from_engine(self, game_type=None):
"""
Call the Jackify engine with 'list-modlists' and return a list of modlist dicts.
Each dict should have at least 'id', 'game', 'download_size', 'install_size', 'total_size', and status flags.
Args:
game_type (str, optional): Filter by game type (e.g., "Skyrim", "Fallout New Vegas")
"""
from .modlist_install_cli import get_jackify_engine_path
engine_executable = get_jackify_engine_path()
engine_dir = os.path.dirname(engine_executable)
if not os.path.exists(engine_executable):
print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_RESET}")
print(f"{COLOR_INFO}Expected: {engine_executable}{COLOR_RESET}")
return []
env = os.environ.copy()
env["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1"
command = [engine_executable, 'list-modlists', '--show-all-sizes', '--show-machine-url']
# Add game filter if specified
if game_type:
command.extend(['--game', game_type])
try:
result = subprocess.run(
command,
capture_output=True, text=True, check=True,
env=env, cwd=engine_dir
)
lines = result.stdout.splitlines()
modlists = []
for line in lines:
line = line.strip()
if not line or line.startswith('Loading') or line.startswith('Loaded'):
continue
# Parse the new format: [STATUS] Modlist Name - Game - Download|Install|Total - MachineURL
# STATUS indicators: [DOWN], [NSFW], or both [DOWN] [NSFW]
# Extract status indicators
status_down = '[DOWN]' in line
status_nsfw = '[NSFW]' in line
# Remove status indicators to get clean line
clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip()
# Split from right to handle modlist names with dashes
# Format: "NAME - GAME - SIZES - MACHINE_URL"
parts = clean_line.rsplit(' - ', 3) # Split from right, max 3 splits = 4 parts
if len(parts) != 4:
continue # Skip malformed lines
modlist_name = parts[0].strip()
game_name = parts[1].strip()
sizes_str = parts[2].strip()
machine_url = parts[3].strip()
# Parse sizes: "Download|Install|Total" (e.g., "203GB|130GB|333GB")
size_parts = sizes_str.split('|')
if len(size_parts) != 3:
continue # Skip if sizes don't match expected format
download_size = size_parts[0].strip()
install_size = size_parts[1].strip()
total_size = size_parts[2].strip()
# Skip if any required data is missing
if not modlist_name or not game_name or not machine_url:
continue
modlists.append({
'id': modlist_name, # Use modlist name as ID for compatibility
'name': modlist_name,
'game': game_name,
'download_size': download_size,
'install_size': install_size,
'total_size': total_size,
'machine_url': machine_url, # Store machine URL for installation
'status_down': status_down,
'status_nsfw': status_nsfw
})
return modlists
except subprocess.CalledProcessError as e:
self.logger.error(f"list-modlists failed. Code: {e.returncode}")
if e.stdout: self.logger.error(f"Engine stdout:\n{e.stdout}")
if e.stderr: self.logger.error(f"Engine stderr:\n{e.stderr}")
print(f"{COLOR_ERROR}Failed to fetch modlist list. Engine error (Code: {e.returncode}).{COLOR_ERROR}")
return []
except Exception as e:
self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True)
print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_ERROR}")
return []
def _enhance_nexus_error(self, line: str) -> str:
"""
Enhance Nexus download error messages by adding the mod URL for easier troubleshooting.
"""
import re
# Pattern to match Nexus download errors with ModID and FileID
nexus_error_pattern = r"Failed to download '[^']+' from Nexus \(Game: ([^,]+), ModID: (\d+), FileID: \d+\):"
match = re.search(nexus_error_pattern, line)
if match:
game_name = match.group(1)
mod_id = match.group(2)
# Map game names to Nexus URL segments
game_url_map = {
'SkyrimSpecialEdition': 'skyrimspecialedition',
'Skyrim': 'skyrim',
'Fallout4': 'fallout4',
'FalloutNewVegas': 'newvegas',
'Oblivion': 'oblivion',
'Starfield': 'starfield'
}
game_url = game_url_map.get(game_name, game_name.lower())
mod_url = f"https://www.nexusmods.com/{game_url}/mods/{mod_id}"
# Add URL on next line for easier debugging
return f"{line}\n Nexus URL: {mod_url}"
return line

View File

@@ -0,0 +1,180 @@
"""TTW integration methods for ModlistInstallCLI (Mixin)."""
import logging
import os
from pathlib import Path
from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET
logger = logging.getLogger(__name__)
class ModlistInstallCLITTWMixin:
"""Mixin providing TTW integration methods."""
def _check_and_prompt_ttw_integration(self, install_dir: str, game_type: str, modlist_name: str):
"""Check if modlist is eligible for TTW integration and prompt user"""
try:
# Check eligibility: FNV game, TTW-compatible modlist, no existing TTW
if not self._is_ttw_eligible(install_dir, game_type, modlist_name):
return
# Prompt user for TTW installation
print(f"\n{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
print(f"{COLOR_INFO}TTW Integration Available{COLOR_RESET}")
print(f"{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
print(f"\nThis modlist ({modlist_name}) supports Tale of Two Wastelands (TTW).")
print(f"TTW combines Fallout 3 and New Vegas into a single game.")
print(f"\nWould you like to install TTW now?")
user_input = input(f"{COLOR_PROMPT}Install TTW? (yes/no): {COLOR_RESET}").strip().lower()
if user_input in ['yes', 'y']:
self._launch_ttw_installation(modlist_name, install_dir)
else:
print(f"{COLOR_INFO}Skipping TTW installation. You can install it later from the main menu.{COLOR_RESET}")
except Exception as e:
self.logger.error(f"Error during TTW eligibility check: {e}", exc_info=True)
def _is_ttw_eligible(self, install_dir: str, game_type: str, modlist_name: str) -> bool:
"""Check if modlist is eligible for TTW integration"""
try:
from pathlib import Path
# Check 1: Must be Fallout New Vegas
if not game_type or game_type.lower() not in ['falloutnv', 'fallout new vegas', 'fallout_new_vegas']:
return False
# Check 2: Must be on TTW compatibility whitelist
from jackify.backend.data.ttw_compatible_modlists import is_ttw_compatible
if not is_ttw_compatible(modlist_name):
return False
# Check 3: TTW must not already be installed
if self._detect_existing_ttw(install_dir):
self.logger.info(f"TTW already installed in {install_dir}, skipping prompt")
return False
return True
except Exception as e:
self.logger.error(f"Error checking TTW eligibility: {e}")
return False
def _detect_existing_ttw(self, install_dir: str) -> bool:
"""Detect if TTW is already installed in the modlist"""
try:
from pathlib import Path
install_path = Path(install_dir)
# Search for TTW indicators in common locations
search_paths = [
install_path,
install_path / "mods",
install_path / "Stock Game",
install_path / "Game Root"
]
for search_path in search_paths:
if not search_path.exists():
continue
# Look for folders containing "tale" and "two" and "wastelands"
for folder in search_path.iterdir():
if not folder.is_dir():
continue
folder_name_lower = folder.name.lower()
if all(keyword in folder_name_lower for keyword in ['tale', 'two', 'wastelands']):
# Verify it has the TTW ESM file
for file in folder.rglob('*.esm'):
if 'taleoftwowastelands' in file.name.lower():
self.logger.info(f"Found existing TTW installation: {file}")
return True
return False
except Exception as e:
self.logger.error(f"Error detecting existing TTW: {e}")
return False
def _launch_ttw_installation(self, modlist_name: str, install_dir: str):
"""Launch TTW installation workflow"""
try:
print(f"\n{COLOR_INFO}Starting TTW installation workflow...{COLOR_RESET}")
# Import TTW installation handler
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
from jackify.backend.models.configuration import SystemInfo
from pathlib import Path
system_info = SystemInfo()
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 TTW_Linux_Installer is installed
ttw_installer_handler._check_installation()
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 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 TTW_Linux_Installer: {message}{COLOR_RESET}")
return
print(f"{COLOR_INFO}TTW_Linux_Installer installed successfully.{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}")
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 = default_ttw_dir
# 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, message = ttw_installer_handler.install_ttw_backend(Path(mpi_path), Path(ttw_install_dir))
if success:
print(f"\n{COLOR_INFO}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
print(f"{COLOR_INFO}TTW Installation Complete!{COLOR_RESET}")
print(f"{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
print(f"\nTTW has been installed to: {ttw_install_dir}")
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)
print(f"{COLOR_ERROR}Error during TTW installation: {e}{COLOR_RESET}")

View File

@@ -0,0 +1,543 @@
"""Wine/Proton operation methods for ModlistHandler (Mixin)."""
from pathlib import Path
from typing import Tuple, Optional, List
import os
import logging
import subprocess
import shutil
import time
import vdf
import json
import configparser
logger = logging.getLogger(__name__)
class ModlistWineOpsMixin:
"""Mixin providing Wine and Proton operation methods for ModlistHandler."""
def verify_proton_setup(self, appid_to_check: str) -> Tuple[bool, str]:
"""Verifies that Proton is correctly set up for a given AppID.
Checks config.vdf for Proton Experimental and existence of compatdata/pfx dir.
Args:
appid_to_check: The AppID string to verify.
Returns:
tuple: (bool success, str status_code)
Status codes: 'ok', 'invalid_appid', 'config_vdf_missing',
'config_vdf_error', 'proton_check_failed',
'wrong_proton_version', 'compatdata_missing',
'prefix_missing'
"""
self.logger.info(f"Verifying Proton setup for AppID: {appid_to_check}")
if not appid_to_check or not appid_to_check.isdigit():
self.logger.error("Invalid AppID provided for verification.")
return False, 'invalid_appid'
proton_tool_name = None
compatdata_path_found = None
prefix_exists = False
# 1. Find and Parse config.vdf
config_vdf_path = None
possible_steam_paths = [
Path.home() / ".steam/steam",
Path.home() / ".local/share/Steam",
Path.home() / ".steam/root"
]
for steam_path in possible_steam_paths:
potential_path = steam_path / "config/config.vdf"
if potential_path.is_file():
config_vdf_path = potential_path
self.logger.debug(f"Found config.vdf at: {config_vdf_path}")
break
if not config_vdf_path:
self.logger.error("Could not locate Steam's config.vdf file.")
return False, 'config_vdf_missing'
# Add a short delay to allow Steam to potentially finish writing changes
self.logger.debug("Waiting 2 seconds before reading config.vdf...")
time.sleep(2)
try:
self.logger.debug(f"Attempting to load VDF file: {config_vdf_path}")
# CORRECTION: Use the vdf library directly here, not VDFHandler
with open(str(config_vdf_path), 'r') as f:
config_data = vdf.load(f, mapper=vdf.VDFDict)
# --- Write full config.vdf to a debug file ---
debug_dump_path = os.path.expanduser("~/dev/Jackify/configvdf_dump.txt")
with open(debug_dump_path, "w") as dump_f:
json.dump(config_data, dump_f, indent=2)
self.logger.info(f"Full config.vdf dumped to {debug_dump_path}")
# --- Log only the relevant section for this AppID ---
steam_config_section = config_data.get('InstallConfigStore', {}).get('Software', {}).get('Valve', {}).get('Steam', {})
compat_mapping = steam_config_section.get('CompatToolMapping', {})
app_mapping = compat_mapping.get(appid_to_check, {})
self.logger.debug("───────────────────────────────────────────────────────────────────")
self.logger.debug(f"Config.vdf entry for AppID {appid_to_check} (CompatToolMapping):")
self.logger.debug(json.dumps({appid_to_check: app_mapping}, indent=2))
self.logger.debug("───────────────────────────────────────────────────────────────────")
self.logger.debug(f"Steam config section from VDF: {json.dumps(steam_config_section, indent=2)}")
# --- End Debugging ---
# Navigate the structure: Software -> Valve -> Steam -> CompatToolMapping -> appid_to_check -> Name
compat_mapping = steam_config_section.get('CompatToolMapping', {})
app_mapping = compat_mapping.get(appid_to_check, {})
proton_tool_name = app_mapping.get('name') # CORRECTED: Use lowercase 'name'
self.proton_ver = proton_tool_name # Store detected version
if proton_tool_name:
self.logger.info(f"Proton tool name from config.vdf: {proton_tool_name}")
else:
self.logger.warning(f"CompatToolMapping entry not found for AppID {appid_to_check} in config.vdf.")
# Add more debug info here about what *was* found
self.logger.debug(f"CompatToolMapping contents: {json.dumps(compat_mapping.get(appid_to_check, 'Key not found'), indent=2)}")
return False, 'proton_check_failed' # Compatibility not explicitly set
except FileNotFoundError:
self.logger.error(f"Config.vdf file not found during load attempt: {config_vdf_path}")
return False, 'config_vdf_missing'
except Exception as e:
self.logger.error(f"Error parsing config.vdf: {e}", exc_info=True)
return False, 'config_vdf_error'
# 2. Check if the correct Proton version is set (allowing variations)
# Target: Proton Experimental
if not proton_tool_name or 'experimental' not in proton_tool_name.lower():
self.logger.warning(f"Incorrect Proton version detected: '{proton_tool_name}'. Expected 'Proton Experimental'.")
return False, 'wrong_proton_version'
self.logger.info("Proton version check passed ('Proton Experimental' set).")
# 3. Check for compatdata / prefix directory existence
possible_compat_bases = [
Path.home() / ".steam/steam/steamapps/compatdata",
Path.home() / ".local/share/Steam/steamapps/compatdata",
# Add SD card paths if necessary / detectable
# Path("/run/media/mmcblk0p1/steamapps/compatdata") # Example
]
compat_dir_found = False
for base_path in possible_compat_bases:
potential_compat_path = base_path / appid_to_check
if potential_compat_path.is_dir():
self.logger.debug(f"Found compatdata directory: {potential_compat_path}")
compat_dir_found = True
# Check for prefix *within* the found compatdata dir
prefix_path = potential_compat_path / "pfx"
if prefix_path.is_dir():
self.logger.info(f"Wine prefix directory verified: {prefix_path}")
prefix_exists = True
break # Found both compatdata and prefix, exit loop
else:
self.logger.warning(f"Compatdata directory found, but prefix missing: {prefix_path}")
# Keep searching other base paths in case prefix exists elsewhere
if not compat_dir_found:
self.logger.error(f"Compatdata directory not found for AppID {appid_to_check} in standard locations.")
return False, 'compatdata_missing'
if not prefix_exists:
# Found compatdata but no pfx inside any of them
self.logger.error(f"Wine prefix directory (pfx) not found within any located compatdata directory for AppID {appid_to_check}.")
return False, 'prefix_missing'
# All checks passed
self.logger.info(f"Proton setup verification successful for AppID {appid_to_check}.")
return True, 'ok'
def set_steam_grid_images(self, appid: str, modlist_dir: str):
"""
Copies hero, logo, and poster images from the modlist's SteamIcons directory
to the grid directory of all non-zero Steam user directories, named after the new AppID.
"""
steam_icons_dir = Path(modlist_dir) / "SteamIcons"
if not steam_icons_dir.is_dir():
self.logger.info(f"No SteamIcons directory found at {steam_icons_dir}, skipping grid image copy.")
return
# Find all non-zero Steam user directories
userdata_base = Path.home() / ".steam/steam/userdata"
if not userdata_base.is_dir():
self.logger.error(f"Steam userdata directory not found at {userdata_base}")
return
for user_dir in userdata_base.iterdir():
if not user_dir.is_dir() or user_dir.name == "0":
continue
grid_dir = user_dir / "config/grid"
grid_dir.mkdir(parents=True, exist_ok=True)
images = [
("grid-hero.png", f"{appid}_hero.png"),
("grid-logo.png", f"{appid}_logo.png"),
("grid-tall.png", f"{appid}.png"),
("grid-tall.png", f"{appid}p.png"),
]
for src_name, dest_name in images:
src_path = steam_icons_dir / src_name
dest_path = grid_dir / dest_name
if src_path.exists():
try:
shutil.copyfile(src_path, dest_path)
self.logger.info(f"Copied {src_path} to {dest_path}")
except Exception as e:
self.logger.error(f"Failed to copy {src_path} to {dest_path}: {e}")
else:
self.logger.warning(f"Image {src_path} not found; skipping.")
def get_modlist_wine_components(self, modlist_name, game_var_full=None):
"""
Returns the full list of Wine components to install for a given modlist/game.
- Always includes the default set (fontsmooth=rgb, xact, xact_x64, vcrun2022)
- Adds game-specific extras (from bash script logic)
- Adds any modlist-specific extras (from MODLIST_WINE_COMPONENTS)
"""
default_components = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"]
extras = []
# Determine game type
game = (game_var_full or modlist_name or "").lower().replace(" ", "")
# Add game-specific extras
if "skyrim" in game or "fallout4" in game or "starfield" in game or "oblivion_remastered" in game or "enderal" in game:
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7"]
elif "falloutnewvegas" in game or "fnv" in game or "oblivion" in game:
extras += ["d3dx9_43", "d3dx9"]
# Add modlist-specific extras
modlist_lower = modlist_name.lower().replace(" ", "") if modlist_name else ""
for key, components in self.MODLIST_WINE_COMPONENTS.items():
if key in modlist_lower:
extras += components
# Remove duplicates while preserving order
seen = set()
full_list = [x for x in default_components + extras if not (x in seen or seen.add(x))]
return full_list
def _re_enforce_windows_10_mode(self):
"""
Re-enforce Windows 10 mode after modlist-specific configurations.
This matches the legacy script behavior (line 1333) where Windows 10 mode
is re-applied after modlist-specific steps to ensure consistency.
"""
try:
if not hasattr(self, 'appid') or not self.appid:
self.logger.warning("Cannot re-enforce Windows 10 mode - no AppID available")
return
from ..handlers.winetricks_handler import WinetricksHandler
from ..handlers.path_handler import PathHandler
# Get prefix path for the AppID
prefix_path = PathHandler.find_compat_data(str(self.appid))
if not prefix_path:
self.logger.warning("Cannot re-enforce Windows 10 mode - prefix path not found")
return
# Use winetricks handler to set Windows 10 mode
winetricks_handler = WinetricksHandler()
wine_binary = winetricks_handler._get_wine_binary_for_prefix(str(prefix_path))
if not wine_binary:
self.logger.warning("Cannot re-enforce Windows 10 mode - wine binary not found")
return
winetricks_handler._set_windows_10_mode(str(prefix_path), wine_binary)
self.logger.info("Windows 10 mode re-enforced after modlist-specific configurations")
except Exception as e:
self.logger.warning(f"Error re-enforcing Windows 10 mode: {e}")
def _handle_symlinked_downloads(self) -> bool:
"""
Check if downloads_directory in ModOrganizer.ini points to a symlink.
If it does, comment out the line to force MO2 to use default behavior.
Returns:
bool: True on success or no action needed, False on error
"""
try:
if not self.modlist_ini or not os.path.exists(self.modlist_ini):
self.logger.warning("ModOrganizer.ini not found for symlink check")
return True # Non-critical
# Read the INI file
# Allow duplicate sections/keys since some ModOrganizer.ini variants repeat [General]
# Latest occurrence wins, which matches how we only need the final downloads_directory value.
config = configparser.ConfigParser(allow_no_value=True, delimiters=['='], strict=False)
config.optionxform = str # Preserve case sensitivity
try:
# Read file manually to handle BOM
with open(self.modlist_ini, 'r', encoding='utf-8-sig') as f:
config.read_file(f)
except UnicodeDecodeError:
with open(self.modlist_ini, 'r', encoding='latin-1') as f:
config.read_file(f)
# Check if downloads_directory or download_directory exists and is a symlink
downloads_key = None
downloads_path = None
if 'General' in config:
# Check for both possible key names
if 'downloads_directory' in config['General']:
downloads_key = 'downloads_directory'
downloads_path = config['General']['downloads_directory']
elif 'download_directory' in config['General']:
downloads_key = 'download_directory'
downloads_path = config['General']['download_directory']
if downloads_path:
if downloads_path and os.path.exists(downloads_path):
# Check if the path or any parent directory contains symlinks
def has_symlink_in_path(path):
"""Check if path or any parent directory is a symlink"""
current_path = Path(path).resolve()
check_path = Path(path)
# Walk up the path checking each component
for parent in [check_path] + list(check_path.parents):
if parent.is_symlink():
return True, str(parent)
return False, None
has_symlink, symlink_path = has_symlink_in_path(downloads_path)
if has_symlink:
self.logger.info(f"Detected symlink in downloads directory path: {symlink_path} -> {downloads_path}")
self.logger.info("Commenting out downloads_directory to avoid Wine symlink issues")
# Read the file manually to preserve comments and formatting
with open(self.modlist_ini, 'r', encoding='utf-8') as f:
lines = f.readlines()
# Find and comment out the downloads directory line
modified = False
for i, line in enumerate(lines):
if line.strip().startswith(f'{downloads_key}='):
lines[i] = '#' + line # Comment out the line
modified = True
break
if modified:
# Write the modified file back
with open(self.modlist_ini, 'w', encoding='utf-8') as f:
f.writelines(lines)
self.logger.info(f"{downloads_key} line commented out successfully")
else:
self.logger.warning("downloads_directory line not found in file")
else:
self.logger.debug(f"downloads_directory is not a symlink: {downloads_path}")
else:
self.logger.debug("downloads_directory path does not exist or is empty")
else:
self.logger.debug("No downloads_directory found in ModOrganizer.ini")
return True
except Exception as e:
self.logger.error(f"Error handling symlinked downloads: {e}", exc_info=True)
return False
def _apply_universal_dotnet_fixes(self):
"""
Apply universal dotnet4.x compatibility registry fixes to ALL modlists.
Now called AFTER wine component installation to prevent overwrites.
Includes wineserver shutdown/flush to ensure persistence.
"""
try:
prefix_path = os.path.join(str(self.compat_data_path), "pfx")
if not os.path.exists(prefix_path):
self.logger.warning(f"Prefix path not found: {prefix_path}")
return False
self.logger.info("Applying universal dotnet4.x compatibility registry fixes (post-component installation)...")
# Find the appropriate Wine binary to use for registry operations
wine_binary = self._find_wine_binary_for_registry()
if not wine_binary:
self.logger.error("Could not find Wine binary for registry operations")
return False
# Find wineserver binary for flushing registry changes
wine_dir = os.path.dirname(wine_binary)
wineserver_binary = os.path.join(wine_dir, 'wineserver')
if not os.path.exists(wineserver_binary):
self.logger.warning(f"wineserver not found at {wineserver_binary}, registry flush may not work")
wineserver_binary = None
# Set environment for Wine registry operations
env = os.environ.copy()
env['WINEPREFIX'] = prefix_path
env['WINEDEBUG'] = '-all' # Suppress Wine debug output
# Shutdown any running wineserver processes to ensure clean slate
if wineserver_binary:
self.logger.debug("Shutting down wineserver before applying registry fixes...")
try:
subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True)
self.logger.debug("Wineserver shutdown complete")
except Exception as e:
self.logger.warning(f"Wineserver shutdown failed (non-critical): {e}")
# Registry fix 1: Set *mscoree=native DLL override (asterisk for full override)
# Use native .NET runtime instead of Wine's
self.logger.debug("Setting *mscoree=native DLL override...")
cmd1 = [
wine_binary, 'reg', 'add',
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
'/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
]
result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30)
self.logger.info(f"*mscoree registry command result: returncode={result1.returncode}, stdout={result1.stdout[:200]}, stderr={result1.stderr[:200]}")
if result1.returncode == 0:
self.logger.info("Successfully applied *mscoree=native DLL override")
else:
self.logger.error(f"Failed to set *mscoree DLL override: returncode={result1.returncode}, stderr={result1.stderr}")
# Registry fix 2: Set OnlyUseLatestCLR=1
# Use latest CLR to avoid .NET version conflicts
self.logger.debug("Setting OnlyUseLatestCLR=1 registry entry...")
cmd2 = [
wine_binary, 'reg', 'add',
'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework',
'/v', 'OnlyUseLatestCLR', '/t', 'REG_DWORD', '/d', '1', '/f'
]
result2 = subprocess.run(cmd2, env=env, capture_output=True, text=True, errors='replace', timeout=30)
self.logger.info(f"OnlyUseLatestCLR registry command result: returncode={result2.returncode}, stdout={result2.stdout[:200]}, stderr={result2.stderr[:200]}")
if result2.returncode == 0:
self.logger.info("Successfully applied OnlyUseLatestCLR=1 registry entry")
else:
self.logger.error(f"Failed to set OnlyUseLatestCLR: returncode={result2.returncode}, stderr={result2.stderr}")
# Force wineserver to flush registry changes to disk
if wineserver_binary:
self.logger.debug("Flushing registry changes to disk via wineserver shutdown...")
try:
subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True)
self.logger.debug("Registry changes flushed to disk")
except Exception as e:
self.logger.warning(f"Registry flush failed (non-critical): {e}")
# VERIFICATION: Confirm the registry entries persisted
self.logger.info("Verifying registry entries were applied and persisted...")
verification_passed = True
# Verify *mscoree=native
verify_cmd1 = [
wine_binary, 'reg', 'query',
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
'/v', '*mscoree'
]
verify_result1 = subprocess.run(verify_cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30)
if verify_result1.returncode == 0 and 'native' in verify_result1.stdout:
self.logger.info("VERIFIED: *mscoree=native is set correctly")
else:
self.logger.error(f"VERIFICATION FAILED: *mscoree=native not found in registry. Query output: {verify_result1.stdout}")
verification_passed = False
# Verify OnlyUseLatestCLR=1
verify_cmd2 = [
wine_binary, 'reg', 'query',
'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework',
'/v', 'OnlyUseLatestCLR'
]
verify_result2 = subprocess.run(verify_cmd2, env=env, capture_output=True, text=True, errors='replace', timeout=30)
if verify_result2.returncode == 0 and ('0x1' in verify_result2.stdout or 'REG_DWORD' in verify_result2.stdout):
self.logger.info("VERIFIED: OnlyUseLatestCLR=1 is set correctly")
else:
self.logger.error(f"VERIFICATION FAILED: OnlyUseLatestCLR=1 not found in registry. Query output: {verify_result2.stdout}")
verification_passed = False
# Both fixes applied and verified
if result1.returncode == 0 and result2.returncode == 0 and verification_passed:
self.logger.info("Universal dotnet4.x compatibility fixes applied, flushed, and verified successfully")
return True
else:
self.logger.error("Registry fixes failed verification - fixes may not persist across prefix restarts")
return False
except Exception as e:
self.logger.error(f"Failed to apply universal dotnet4.x fixes: {e}")
return False
def _find_wine_binary_for_registry(self) -> Optional[str]:
"""Find wine binary from Install Proton path"""
try:
# Use Install Proton from config (used by jackify-engine)
from ..handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
proton_path = config_handler.get_proton_path()
if proton_path:
proton_path = Path(proton_path).expanduser()
# Check both GE-Proton and Valve Proton structures
wine_candidates = [
proton_path / "files" / "bin" / "wine", # GE-Proton
proton_path / "dist" / "bin" / "wine" # Valve Proton
]
for wine_bin in wine_candidates:
if wine_bin.exists() and wine_bin.is_file():
return str(wine_bin)
# Fallback: use best detected Proton
from ..handlers.wine_utils import WineUtils
best_proton = WineUtils.select_best_proton()
if best_proton:
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
if wine_binary:
return wine_binary
return None
except Exception as e:
self.logger.error(f"Error finding Wine binary: {e}")
return None
def _search_wine_in_proton_directory(self, proton_path: Path) -> Optional[str]:
"""
Recursively search for wine binary within a Proton directory.
This handles cases where the directory structure might differ between Proton versions.
Args:
proton_path: Path to the Proton directory to search
Returns:
Path to wine binary if found, None otherwise
"""
try:
if not proton_path.exists() or not proton_path.is_dir():
return None
# Search for 'wine' executable (not 'wine64' or 'wine-preloader')
# Limit search depth to avoid scanning entire filesystem
max_depth = 5
for root, dirs, files in os.walk(proton_path, followlinks=False):
# Calculate depth relative to proton_path
depth = len(Path(root).relative_to(proton_path).parts)
if depth > max_depth:
dirs.clear() # Don't descend further
continue
# Check if 'wine' is in this directory
if 'wine' in files:
wine_path = Path(root) / 'wine'
# Verify it's actually an executable file
if wine_path.is_file() and os.access(wine_path, os.X_OK):
self.logger.debug(f"Found wine binary at: {wine_path}")
return str(wine_path)
return None
except Exception as e:
self.logger.debug(f"Error during recursive wine search in {proton_path}: {e}")
return None

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DXVK config mixin for PathHandler.
Extracted from path_handler for file-size and domain separation.
"""
import os
import logging
from pathlib import Path
from typing import Optional, List
logger = logging.getLogger(__name__)
class PathHandlerDXVKMixin:
"""Mixin providing DXVK config creation and verification."""
@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 = PathHandlerDXVKMixin._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 create_dxvk_conf(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full,
vanilla_game_dir=None, stock_game_path=None) -> bool:
"""Create dxvk.conf file in the appropriate location."""
try:
logger.info("Creating dxvk.conf file...")
candidate_dirs = PathHandlerDXVKMixin._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
)
if not candidate_dirs:
logger.error("Could not determine location for dxvk.conf (no candidate directories found)")
return False
target_dir = None
for directory in candidate_dirs:
if directory.is_dir():
target_dir = directory
break
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("All candidate directories for dxvk.conf are missing.")
return False
dxvk_conf_path = target_dir / "dxvk.conf"
required_line = "dxvk.enableGraphicsPipelineLibrary = False"
if dxvk_conf_path.exists():
try:
with open(dxvk_conf_path, 'r', encoding='utf-8') as f:
existing_content = f.read().strip()
existing_lines = existing_content.split('\n') if existing_content else []
has_required_line = any(line.strip() == required_line for line in existing_lines)
if has_required_line:
logger.info("Required DXVK setting already present in existing file")
return True
updated_content = existing_content + '\n' + required_line + '\n' if existing_content else required_line + '\n'
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}")
return True
except Exception as e:
logger.error(f"Error reading/updating existing dxvk.conf: {e}")
logger.info("Falling back to creating new dxvk.conf file")
dxvk_conf_content = required_line + '\n'
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}")
return True
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 candidate directory and contains the required setting."""
required_line = "dxvk.enableGraphicsPipelineLibrary = False"
candidate_dirs = PathHandlerDXVKMixin._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

View File

@@ -0,0 +1,184 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Game path and compatdata mixin for PathHandler.
Extracted from path_handler for file-size and domain separation.
"""
import os
import re
import logging
from pathlib import Path
from typing import Optional, List, Dict
logger = logging.getLogger(__name__)
class PathHandlerGameMixin:
"""Mixin providing game install path and compatdata discovery."""
@classmethod
def find_compat_data(cls, appid: str) -> Optional[Path]:
"""Find the compatdata directory for a given AppID."""
if not appid:
logger.error(f"Invalid AppID provided for compatdata search: {appid}")
return None
appid_clean = appid.lstrip('-')
if not appid_clean.isdigit():
logger.error(f"Invalid AppID provided for compatdata search: {appid}")
return None
logger.debug(f"Searching for compatdata directory for AppID: {appid}")
library_paths = cls.get_all_steam_library_paths()
if library_paths:
logger.debug(f"Checking compatdata in {len(library_paths)} Steam libraries")
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
logger.debug(f"Compatdata for AppID {appid} not found in {compatdata_base}")
is_flatpak_steam = any('.var/app/com.valvesoftware.Steam' in str(lib) for lib in library_paths) if library_paths else False
if not library_paths or is_flatpak_steam:
logger.debug("Checking fallback compatdata locations...")
if is_flatpak_steam:
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:
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: {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
def detect_stock_game_path(game_type: str, steam_library: Path) -> Optional[Path]:
"""Detect the stock game path for a given game type and Steam library."""
try:
game_app_ids = {
'skyrim': '489830', 'fallout4': '377160', 'fnv': '22380', 'oblivion': '22330'
}
if game_type not in game_app_ids:
return None
app_id = game_app_ids[game_type]
game_path = steam_library / 'steamapps' / 'common'
possible_names = {
'skyrim': ['Skyrim Special Edition', 'Skyrim'],
'fallout4': ['Fallout 4'],
'fnv': ['Fallout New Vegas', 'FalloutNV'],
'oblivion': ['Oblivion']
}
if game_type not in possible_names:
return None
for name in possible_names[game_type]:
potential_path = game_path / name
if potential_path.exists():
return potential_path
return None
except Exception as e:
logging.error(f"Error detecting stock game path: {e}")
return None
@classmethod
def find_game_install_paths(cls, target_appids: Dict[str, str]) -> Dict[str, Path]:
"""Find installation paths for multiple specified games using Steam app IDs."""
library_paths = cls.get_all_steam_library_paths()
if not library_paths:
logger.warning("Failed to find any Steam library paths")
return {}
results = {}
for library_path in library_paths:
common_dir = library_path / "steamapps" / "common"
if not common_dir.is_dir():
logger.debug(f"No 'steamapps/common' directory in library: {library_path}")
continue
try:
game_dirs = [d for d in common_dir.iterdir() if d.is_dir()]
except (PermissionError, OSError) as e:
logger.warning(f"Cannot access directory {common_dir}: {e}")
continue
for game_name, app_id in target_appids.items():
if game_name in results:
continue
appmanifest_path = library_path / "steamapps" / f"appmanifest_{app_id}.acf"
if appmanifest_path.is_file():
try:
with open(appmanifest_path, 'r', encoding='utf-8') as f:
content = f.read()
match = re.search(r'"installdir"\s+"([^"]+)"', content)
if match:
install_dir_name = match.group(1)
install_path = common_dir / install_dir_name
if install_path.is_dir():
results[game_name] = install_path
logger.info(f"Found {game_name} at {install_path}")
continue
except Exception as e:
logger.warning(f"Error reading appmanifest for {game_name}: {e}")
return results
@classmethod
def find_vanilla_game_paths(cls, game_names=None) -> Dict[str, Path]:
"""For each known game, iterate all Steam libraries and look for the canonical game directory in steamapps/common."""
GAME_DIR_NAMES = {
"Skyrim Special Edition": ["Skyrim Special Edition"],
"Fallout 4": ["Fallout 4"],
"Fallout New Vegas": ["Fallout New Vegas"],
"Oblivion": ["Oblivion"],
"Fallout 3": ["Fallout 3", "Fallout 3 goty"]
}
if game_names is None:
game_names = list(GAME_DIR_NAMES.keys())
all_steam_libraries = cls.get_all_steam_library_paths()
logger.info(f"[DEBUG] Detected Steam libraries: {all_steam_libraries}")
found_games = {}
for game in game_names:
possible_names = GAME_DIR_NAMES.get(game, [game])
for lib in all_steam_libraries:
for name in possible_names:
candidate = lib / "steamapps" / "common" / name
logger.info(f"[DEBUG] Checking for vanilla game directory: {candidate}")
if candidate.is_dir():
found_games[game] = candidate
logger.info(f"Found vanilla game directory for {game}: {candidate}")
break
if game in found_games:
break
return found_games
def _detect_stock_game_path(self) -> bool:
"""Detects common Stock Game or Game Root directories within the modlist path. Expects self.logger, self.modlist_dir, self.stock_game_path."""
self.logger.info("Step 7a: Detecting Stock Game/Game Root directory...")
if not self.modlist_dir:
self.logger.error("Modlist directory not set, cannot detect stock game path.")
return False
modlist_path = Path(self.modlist_dir)
preferred_order = [
"Stock Game", "STOCK GAME", "Skyrim Stock", "Stock Game Folder",
"Stock Folder", Path("root/Skyrim Special Edition"), "Game Root"
]
found_path = None
for name in preferred_order:
potential_path = modlist_path / name
if potential_path.is_dir():
found_path = str(potential_path)
self.logger.info(f"Found potential stock game directory: {found_path}")
break
if found_path:
self.stock_game_path = found_path
return True
self.stock_game_path = None
self.logger.info("No common Stock Game/Game Root directory found. Will assume vanilla game path is needed for some operations.")
return True

View File

@@ -0,0 +1,492 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MO2 INI and path formatting mixin for PathHandler.
Extracted from path_handler for file-size and domain separation.
"""
import os
import re
import shutil
import logging
from pathlib import Path
from typing import Optional, List
from datetime import datetime
from .wine_utils import WineUtils
logger = logging.getLogger(__name__)
TARGET_EXECUTABLES_LOWER = [
"skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe",
"sfse_loader.exe", "obse64_loader.exe", "falloutnv.exe"
]
STOCK_GAME_FOLDERS = ["Stock Game", "Game Root", "Stock Folder", "Skyrim Stock"]
SDCARD_PREFIX = '/run/media/mmcblk0p1/'
class PathHandlerMO2Mixin:
"""Mixin providing ModOrganizer.ini path updates and formatting."""
@staticmethod
def _strip_sdcard_path_prefix(path_obj: Path) -> str:
"""Removes SD card mount prefix. Returns path as POSIX-style string."""
path_str = path_obj.as_posix()
stripped_path = WineUtils._strip_sdcard_path(path_str)
if stripped_path != path_str:
return stripped_path.lstrip('/') if stripped_path != '/' else '.'
return path_str
@classmethod
def update_mo2_ini_paths(
cls,
modlist_ini_path: Path,
modlist_dir_path: Path,
modlist_sdcard: bool,
steam_library_common_path: Optional[Path] = None,
basegame_dir_name: Optional[str] = None,
basegame_sdcard: bool = False
) -> bool:
"""Update gamePath, binary, and workingDirectory in ModOrganizer.ini."""
logger.info(f"[DEBUG] update_mo2_ini_paths called with: modlist_ini_path={modlist_ini_path}, modlist_dir_path={modlist_dir_path}, modlist_sdcard={modlist_sdcard}, steam_library_common_path={steam_library_common_path}, basegame_dir_name={basegame_dir_name}, basegame_sdcard={basegame_sdcard}")
if not modlist_ini_path.is_file():
logger.error(f"ModOrganizer.ini not found at specified path: {modlist_ini_path}")
try:
logger.warning("Creating minimal ModOrganizer.ini with [General] section.")
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
f.write('[General]\n')
except Exception as e:
logger.critical(f"Failed to create minimal ModOrganizer.ini: {e}")
return False
if not modlist_dir_path.is_dir():
logger.error(f"Modlist directory not found or not a directory: {modlist_dir_path}")
all_steam_libraries = cls.get_all_steam_library_paths()
logger.info(f"[DEBUG] Detected Steam libraries: {all_steam_libraries}")
import sys
if hasattr(sys, 'argv') and any(arg in ('--debug', '-d') for arg in sys.argv):
logger.debug(f"Detected Steam libraries: {all_steam_libraries}")
GAME_DIR_NAMES = {
"Skyrim Special Edition": "Skyrim Special Edition",
"Fallout 4": "Fallout 4",
"Fallout New Vegas": "Fallout New Vegas",
"Oblivion": "Oblivion"
}
canonical_name = GAME_DIR_NAMES.get(basegame_dir_name, basegame_dir_name) if basegame_dir_name else None
gamepath_target_dir = None
gamepath_target_is_sdcard = modlist_sdcard
checked_candidates = []
if canonical_name:
for lib in all_steam_libraries:
candidate = lib / "steamapps" / "common" / canonical_name
checked_candidates.append(str(candidate))
logger.info(f"[DEBUG] Checking for vanilla game directory: {candidate}")
if candidate.is_dir():
gamepath_target_dir = candidate
logger.info(f"Found vanilla game directory: {candidate}")
break
if not gamepath_target_dir:
logger.error(f"Could not find vanilla game directory '{canonical_name}' in any Steam library. Checked: {checked_candidates}")
print("\nCould not automatically detect a Stock Game or vanilla game directory.")
print("Please enter the full path to your vanilla game directory (e.g., /path/to/Skyrim Special Edition):")
while True:
user_input = input("Game directory path: ").strip()
user_path = Path(user_input)
logger.info(f"[DEBUG] User entered: {user_input}")
if user_path.is_dir():
exe_candidates = list(user_path.glob('*.exe'))
logger.info(f"[DEBUG] .exe files in user path: {exe_candidates}")
if exe_candidates:
gamepath_target_dir = user_path
logger.info(f"User provided valid vanilla game directory: {gamepath_target_dir}")
break
print("Directory exists but does not appear to contain the game executable. Please check and try again.")
logger.warning("User path exists but no .exe files found.")
else:
print("Directory not found. Please enter a valid path.")
logger.warning("User path does not exist.")
if not gamepath_target_dir:
logger.critical("[FATAL] Could not determine a valid target directory for gamePath. Check configuration and paths. Aborting update.")
return False
logger.debug(f"Determined gamePath target directory: {gamepath_target_dir}")
logger.debug(f"gamePath target is on SD card: {gamepath_target_is_sdcard}")
try:
logger.debug(f"Reading original INI file: {modlist_ini_path}")
with open(modlist_ini_path, 'r', encoding='utf-8', errors='ignore') as f:
original_lines = f.readlines()
gamepath_line_num = -1
general_section_line = -1
for i, line in enumerate(original_lines):
if re.match(r'^\s*\[General\]\s*$', line, re.IGNORECASE):
general_section_line = i
if re.match(r'^\s*gamepath\s*=\s*', line, re.IGNORECASE):
gamepath_line_num = i
break
processed_str = PathHandlerMO2Mixin._strip_sdcard_path_prefix(gamepath_target_dir)
windows_style_single = processed_str.replace('/', '\\')
gamepath_drive_letter = "D:" if gamepath_target_is_sdcard else "Z:"
formatted_gamepath = PathHandlerMO2Mixin._format_gamepath_for_mo2(f'{gamepath_drive_letter}{windows_style_single}')
new_gamepath_line = f'gamePath = @ByteArray({formatted_gamepath})\n'
if gamepath_line_num != -1:
logger.info(f"Updating existing gamePath line: {original_lines[gamepath_line_num].strip()} -> {new_gamepath_line.strip()}")
original_lines[gamepath_line_num] = new_gamepath_line
else:
insert_at = general_section_line + 1 if general_section_line != -1 else 0
logger.info(f"Adding missing gamePath line at line {insert_at+1}: {new_gamepath_line.strip()}")
original_lines.insert(insert_at, new_gamepath_line)
TARGET_EXEC_LOWER = [
"skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe", "falloutnv.exe"
]
in_custom_exec = False
for i, line in enumerate(original_lines):
if re.match(r'^\s*\[customExecutables\]\s*$', line, re.IGNORECASE):
in_custom_exec = True
continue
if in_custom_exec and re.match(r'^\s*\[.*\]\s*$', line):
in_custom_exec = False
if in_custom_exec:
m = re.match(r'^(\d+)\\binary\s*=\s*(.*)$', line.strip(), re.IGNORECASE)
if m:
idx, old_path = m.group(1), m.group(2)
exe_name = os.path.basename(old_path).lower()
if exe_name in TARGET_EXEC_LOWER:
new_path = f'{gamepath_drive_letter}/{PathHandlerMO2Mixin._strip_sdcard_path_prefix(gamepath_target_dir)}/{exe_name}'
new_path = PathHandlerMO2Mixin._format_binary_for_mo2(new_path)
logger.info(f"Updating binary for entry {idx}: {old_path} -> {new_path}")
original_lines[i] = f'{idx}\\binary = {new_path}\n'
m_wd = re.match(r'^(\d+)\\workingDirectory\s*=\s*(.*)$', line.strip(), re.IGNORECASE)
if m_wd:
idx, old_wd = m_wd.group(1), m_wd.group(2)
new_wd = f'{gamepath_drive_letter}{windows_style_single}'
new_wd = PathHandlerMO2Mixin._format_workingdir_for_mo2(new_wd)
logger.info(f"Updating workingDirectory for entry {idx}: {old_wd} -> {new_wd}")
original_lines[i] = f'{idx}\\workingDirectory = {new_wd}\n'
backup_path = modlist_ini_path.with_suffix(f".{datetime.now().strftime('%Y%m%d_%H%M%S')}.bak")
try:
shutil.copy2(modlist_ini_path, backup_path)
logger.info(f"Backed up original INI to: {backup_path}")
except Exception as bak_err:
logger.error(f"Failed to backup original INI file: {bak_err}")
return False
try:
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
f.writelines(original_lines)
logger.info(f"Successfully wrote updated paths to {modlist_ini_path}")
return True
except Exception as write_err:
logger.error(f"Failed to write updated INI file {modlist_ini_path}: {write_err}", exc_info=True)
logger.error("Attempting to restore from backup...")
try:
shutil.move(backup_path, modlist_ini_path)
logger.info("Successfully restored original INI from backup.")
except Exception as restore_err:
logger.critical(f"CRITICAL FAILURE: Could not write new INI and failed to restore backup {backup_path}. Manual intervention required at {modlist_ini_path}! Error: {restore_err}")
return False
except Exception as e:
logger.error(f"An unexpected error occurred during INI path update: {e}", exc_info=True)
return False
@staticmethod
def edit_resolution(modlist_ini, resolution) -> bool:
"""Edit resolution settings in ModOrganizer.ini. resolution format: '1920x1080'."""
try:
logger.info(f"Editing resolution settings to {resolution}...")
width, height = resolution.split('x')
with open(modlist_ini, 'r') as f:
content = f.read()
content = re.sub(r'^width\s*=\s*\d+$', f'width = {width}', content, flags=re.MULTILINE)
content = re.sub(r'^height\s*=\s*\d+$', f'height = {height}', content, flags=re.MULTILINE)
with open(modlist_ini, 'w') as f:
f.write(content)
logger.info("Resolution settings edited successfully")
return True
except Exception as e:
logger.error(f"Error editing resolution settings: {e}")
return False
def replace_gamepath(self, modlist_ini_path: Path, new_game_path: Path, modlist_sdcard: bool = False) -> bool:
"""Updates the gamePath value in ModOrganizer.ini to the specified path."""
logger.info(f"Replacing gamePath in {modlist_ini_path} with {new_game_path}")
if not modlist_ini_path.is_file():
logger.error(f"ModOrganizer.ini not found at: {modlist_ini_path}")
return False
try:
with open(modlist_ini_path, 'r', encoding='utf-8', errors='ignore') as f:
lines = f.readlines()
drive_letter = "D:\\\\" if modlist_sdcard else "Z:\\\\"
processed_path = self._strip_sdcard_path_prefix(new_game_path)
windows_style = processed_path.replace('/', '\\')
windows_style_double = windows_style.replace('\\', '\\\\')
new_gamepath_line = f'gamePath=@ByteArray({drive_letter}{windows_style_double})\n'
gamepath_found = False
for i, line in enumerate(lines):
if re.match(r'^\s*gamepath\s*=.*$', line, re.IGNORECASE):
lines[i] = new_gamepath_line
gamepath_found = True
break
if not gamepath_found:
logger.error("gamePath line not found in ModOrganizer.ini. Aborting.")
return False
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info("gamePath updated successfully")
return True
except Exception as e:
logger.error(f"Error replacing gamePath: {e}")
return False
def edit_binary_working_paths(self, modlist_ini_path: Path, modlist_dir_path: Path, modlist_sdcard: bool,
steam_libraries: Optional[List[Path]] = None) -> bool:
"""Update all binary paths and working directories in ModOrganizer.ini. Critical, regression-prone."""
try:
logger.debug(f"Updating binary paths and working directories in {modlist_ini_path} to use root: {modlist_dir_path}")
if not modlist_ini_path.is_file():
logger.error(f"INI file {modlist_ini_path} does not exist")
return False
with open(modlist_ini_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
existing_game_path = None
gamepath_drive_letter = None
gamepath_line_index = -1
for i, line in enumerate(lines):
if re.match(r'^\s*gamepath\s*=.*@ByteArray\(([^)]+)\)', line, re.IGNORECASE):
match = re.search(r'@ByteArray\(([^)]+)\)', line)
if match:
raw_path = match.group(1)
gamepath_line_index = i
if raw_path.startswith('Z:'):
gamepath_drive_letter = 'Z:'
elif raw_path.startswith('D:'):
gamepath_drive_letter = 'D:'
if raw_path.startswith(('Z:', 'D:')):
linux_path = raw_path[2:].replace('\\\\', '/').replace('\\', '/')
existing_game_path = linux_path
logger.debug(f"Extracted existing gamePath: {existing_game_path}, drive letter: {gamepath_drive_letter}")
break
if modlist_sdcard and existing_game_path and existing_game_path.startswith('/run/media') and gamepath_line_index != -1:
sdcard_pattern = r'^/run/media/deck/[^/]+(/Games/.*)$'
match = re.match(sdcard_pattern, existing_game_path)
if match:
stripped_path = match.group(1)
windows_path = stripped_path.replace('/', '\\\\')
new_gamepath_value = f"D:\\\\{windows_path}"
new_gamepath_line = f"gamePath = @ByteArray({new_gamepath_value})\n"
logger.info(f"Updating gamePath for SD card: {lines[gamepath_line_index].strip()} -> {new_gamepath_line.strip()}")
lines[gamepath_line_index] = new_gamepath_line
else:
logger.warning(f"SD card path doesn't match expected pattern: {existing_game_path}")
game_path_updated = False
binary_paths_updated = 0
working_dirs_updated = 0
binary_lines = []
working_dir_lines = []
for i, line in enumerate(lines):
stripped = line.strip()
binary_match = re.match(r'^(\d+)(\\+)\s*binary\s*=.*$', stripped, re.IGNORECASE)
if binary_match:
binary_lines.append((i, stripped, binary_match.group(1), binary_match.group(2)))
wd_match = re.match(r'^(\d+)(\\+)\s*workingDirectory\s*=.*$', stripped, re.IGNORECASE)
if wd_match:
working_dir_lines.append((i, stripped, wd_match.group(1), wd_match.group(2)))
binary_paths_by_index = {}
if existing_game_path and '/steamapps/common/' in existing_game_path:
steamapps_index = existing_game_path.find('/steamapps/common/')
steam_lib_root = existing_game_path[:steamapps_index]
steam_libraries = [Path(steam_lib_root)]
logger.info(f"Using Steam library from existing gamePath: {steam_lib_root}")
elif steam_libraries is None or not steam_libraries:
steam_libraries = self.get_all_steam_library_paths()
logger.debug(f"Fallback to detected Steam libraries: {steam_libraries}")
for i, line, index, backslash_style in binary_lines:
parts = line.split('=', 1)
if len(parts) != 2:
logger.error(f"Malformed binary line: {line}")
continue
key_part, value_part = parts
cleaned_value = PathHandlerMO2Mixin._clean_malformed_binary_path(value_part)
exe_name = os.path.basename(cleaned_value).lower()
if exe_name not in TARGET_EXECUTABLES_LOWER:
logger.debug(f"Skipping non-target executable: {exe_name}")
continue
rel_path = None
if 'steamapps' in cleaned_value:
if not gamepath_drive_letter:
logger.warning("Vanilla game path detected but gamePath drive letter not found. Skipping binary path update.")
continue
is_malformed = '"' in cleaned_value or cleaned_value != value_part.strip().strip('"')
idx = cleaned_value.index('steamapps')
subpath = cleaned_value[idx:].lstrip('/')
correct_steam_lib = None
for lib in steam_libraries:
if len(subpath.split('/')) > 3 and (lib / subpath.split('/')[2] / subpath.split('/')[3]).exists():
correct_steam_lib = lib
break
if not correct_steam_lib and steam_libraries:
correct_steam_lib = steam_libraries[0]
if correct_steam_lib:
drive_prefix = gamepath_drive_letter
if is_malformed:
logger.info(f"Fixing malformed binary path for {exe_name}: {value_part.strip()}")
new_binary_path = f"{drive_prefix}/{correct_steam_lib}/{subpath}".replace('\\', '/').replace('//', '/')
else:
logger.error("Could not determine correct Steam library for vanilla game path.")
continue
else:
drive_prefix = "D:" if modlist_sdcard else "Z:"
found_stock = None
for folder in STOCK_GAME_FOLDERS:
folder_pattern = f"/{folder}"
if folder_pattern in cleaned_value:
idx = cleaned_value.index(folder_pattern)
rel_path = cleaned_value[idx:].lstrip('/')
found_stock = folder
break
if not rel_path:
if "/mods/" in cleaned_value:
idx = cleaned_value.index("/mods/")
rel_path = cleaned_value[idx:].lstrip('/')
else:
rel_path = exe_name
processed_modlist_path = self._strip_sdcard_path_prefix(modlist_dir_path) if modlist_sdcard else str(modlist_dir_path)
new_binary_path = f"{drive_prefix}/{processed_modlist_path}/{rel_path}".replace('\\', '/').replace('//', '/')
formatted_binary_path = PathHandlerMO2Mixin._format_binary_for_mo2(new_binary_path)
if '"' in formatted_binary_path:
formatted_binary_path = formatted_binary_path.replace('"', '')
new_binary_line = f"{index}{backslash_style}binary = {formatted_binary_path}"
logger.info(f"Updating binary path: {line.strip()} -> {new_binary_line}")
original_line = lines[i]
lines[i] = new_binary_line + '\n'
binary_paths_updated += 1
binary_paths_by_index[index] = formatted_binary_path
for j, wd_line, index, backslash_style in working_dir_lines:
if index in binary_paths_by_index:
binary_path = binary_paths_by_index[index]
wd_path = os.path.dirname(binary_path)
drive_prefix = "D:" if binary_path.startswith("D:") else "Z:" if binary_path.startswith("Z:") else ("D:" if modlist_sdcard else "Z:")
if wd_path.startswith("D:") or wd_path.startswith("Z:"):
wd_path = wd_path[2:]
wd_path = drive_prefix + wd_path
formatted_wd_path = PathHandlerMO2Mixin._format_workingdir_for_mo2(wd_path)
key_part = f"{index}{backslash_style}workingDirectory"
new_wd_line = f"{key_part} = {formatted_wd_path}"
logger.debug(f"Updating working directory: {wd_line.strip()} -> {new_wd_line}")
original_wd_line = lines[j]
lines[j] = new_wd_line + '\n'
working_dirs_updated += 1
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info(f"edit_binary_working_paths completed: Game path updated: {game_path_updated}, Binary paths updated: {binary_paths_updated}, Working directories updated: {working_dirs_updated}")
return True
except Exception as e:
logger.error(f"Error updating binary paths in {modlist_ini_path}: {str(e)}")
return False
def _format_path_for_mo2(self, path: str) -> str:
"""Format a path for MO2's ModOrganizer.ini file (working directories)."""
formatted = path.replace('/', '\\')
if not re.match(r'^[A-Za-z]:', formatted):
formatted = 'D:' + formatted
formatted = formatted.replace('\\', '\\\\')
return formatted
def _format_binary_path_for_mo2(self, path_str) -> str:
"""Format a binary path for MO2 config file. Binary paths need forward slashes."""
return path_str.replace('\\', '/')
def _format_working_dir_for_mo2(self, path_str) -> str:
"""Format a working directory path for MO2 config file. Ensures double backslashes."""
path = path_str.replace('/', '\\')
path = path.replace('\\', '\\\\')
path = re.sub(r'^([A-Z]:)\\\\+', r'\1\\\\', path)
return path
@staticmethod
def _format_gamepath_for_mo2(path: str) -> str:
path = path.replace('/', '\\')
path = re.sub(r'\\+', r'\\', path)
path = re.sub(r'^([A-Z]:)\\+', r'\1\\', path)
return path
@staticmethod
def _clean_malformed_binary_path(value_part: str) -> str:
"""Clean up malformed binary paths from engine (e.g., quotes in wrong places)."""
cleaned = value_part.strip()
if cleaned.startswith('"') and '"' in cleaned[1:]:
quote_end = cleaned.find('"', 1)
if quote_end > 0:
after_quote = cleaned[quote_end + 1:].strip()
if after_quote.startswith('/') or after_quote:
path_part = cleaned[1:quote_end]
remaining = after_quote.lstrip('/')
cleaned = f"{path_part}/{remaining}" if remaining else path_part
logger.info(f"Cleaned malformed binary path: {value_part} -> {cleaned}")
cleaned = cleaned.strip('"')
cleaned = cleaned.replace('\\', '/')
return cleaned
@staticmethod
def _format_binary_for_mo2(path: str) -> str:
path = path.replace('\\', '/')
path = re.sub(r'^([A-Z]:)//+', r'\1/', path)
return path
@staticmethod
def _format_workingdir_for_mo2(path: str) -> str:
path = path.replace('/', '\\')
path = path.replace('\\', '\\\\')
path = re.sub(r'^([A-Z]:)\\\\+', r'\1\\\\', path)
return path
def set_download_directory(self, modlist_ini_path: Path, download_dir_linux_path, modlist_sdcard: bool) -> bool:
"""
Set download_directory in ModOrganizer.ini to the correct Wine path (Z: or D: for SD card).
Use only when download dir is known (e.g. Install a Modlist flow). Configure New/Existing leave as-is.
"""
if not modlist_ini_path.is_file() or not download_dir_linux_path:
return False
try:
path_obj = Path(download_dir_linux_path)
if modlist_sdcard:
drive = "D:"
path_part = self._strip_sdcard_path_prefix(path_obj)
if path_part.startswith('/'):
path_part = path_part[1:]
path_part = path_part.replace('/', '\\')
else:
drive = "Z:"
path_part = str(path_obj).replace('/', '\\').lstrip('\\')
wine_path = drive + "\\" + path_part
formatted = PathHandlerMO2Mixin._format_workingdir_for_mo2(wine_path)
with open(modlist_ini_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
in_general = False
download_line_idx = -1
for i, line in enumerate(lines):
if re.match(r'^\s*\[General\]\s*$', line, re.IGNORECASE):
in_general = True
continue
if in_general and re.match(r'^\s*\[', line):
break
if in_general and re.match(r'^\s*download_directory\s*=', line, re.IGNORECASE):
download_line_idx = i
break
new_line = f"download_directory = {formatted}\n"
if download_line_idx >= 0:
lines[download_line_idx] = new_line
else:
if in_general:
insert_idx = next((i for i, l in enumerate(lines) if re.match(r'^\s*\[General\]', l, re.I)), -1)
if insert_idx >= 0:
insert_idx += 1
while insert_idx < len(lines) and not re.match(r'^\s*\[', lines[insert_idx]):
insert_idx += 1
lines.insert(insert_idx, new_line)
else:
lines.append("[General]\n")
lines.append(new_line)
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info(f"Set download_directory in ModOrganizer.ini to {formatted}")
return True
except Exception as e:
logger.error(f"Error setting download_directory in {modlist_ini_path}: {e}")
return False

View File

@@ -0,0 +1,226 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Steam path and library mixin for PathHandler.
Extracted from path_handler for file-size and domain separation.
"""
import os
import re
import logging
from pathlib import Path
from typing import Optional, List
from datetime import datetime
import vdf
logger = logging.getLogger(__name__)
class PathHandlerSteamMixin:
"""Mixin providing Steam config, library, and shortcuts path discovery."""
@staticmethod
def find_steam_config_vdf() -> Optional[Path]:
"""Finds the active Steam config.vdf file."""
logger.debug("Searching for Steam config.vdf...")
possible_steam_paths = [
Path.home() / ".steam/steam",
Path.home() / ".local/share/Steam",
Path.home() / ".steam/root"
]
for steam_path in possible_steam_paths:
potential_path = steam_path / "config/config.vdf"
if potential_path.is_file():
logger.info(f"Found config.vdf at: {potential_path}")
return potential_path
logger.warning("Could not locate Steam's config.vdf file in standard locations.")
return None
@staticmethod
def find_steam_library() -> Optional[Path]:
"""Find the primary Steam library common directory containing games."""
logger.debug("Attempting to find Steam library...")
libraryfolders_vdf_paths = [
os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"),
os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"),
os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf"),
]
for path in libraryfolders_vdf_paths:
if os.path.exists(path):
backup_dir = os.path.join(os.path.dirname(path), "backups")
if not os.path.exists(backup_dir):
try:
os.makedirs(backup_dir)
except OSError as e:
logger.warning(f"Could not create backup directory {backup_dir}: {e}")
timestamp = datetime.now().strftime("%Y%m%d")
backup_filename = f"libraryfolders_{timestamp}.vdf.bak"
backup_path = os.path.join(backup_dir, backup_filename)
if not os.path.exists(backup_path):
try:
import shutil
shutil.copy2(path, backup_path)
logger.debug(f"Created backup of libraryfolders.vdf at {backup_path}")
except Exception as e:
logger.error(f"Failed to create backup of libraryfolders.vdf: {e}")
libraryfolders_vdf_path_obj = None
found_path_str = None
for path_str in libraryfolders_vdf_paths:
if os.path.exists(path_str):
found_path_str = path_str
libraryfolders_vdf_path_obj = Path(path_str)
logger.debug(f"Found libraryfolders.vdf at: {path_str}")
break
if not libraryfolders_vdf_path_obj or not libraryfolders_vdf_path_obj.is_file():
logger.warning("libraryfolders.vdf not found or is not a file. Cannot automatically detect Steam Library.")
return None
library_paths = []
try:
with open(found_path_str, 'r') as f:
content = f.read()
path_matches = re.finditer(r'"path"\s*"([^"]+)"', content)
for match in path_matches:
library_path_str = match.group(1).replace('\\\\', '\\')
common_path = os.path.join(library_path_str, "steamapps", "common")
if os.path.isdir(common_path):
library_paths.append(Path(common_path))
logger.debug(f"Found potential common path: {common_path}")
else:
logger.debug(f"Skipping non-existent common path derived from VDF: {common_path}")
logger.debug(f"Found {len(library_paths)} valid library common paths from VDF.")
if library_paths:
logger.info(f"Using Steam library common path: {library_paths[0]}")
return library_paths[0]
logger.debug("No valid common paths found in VDF, checking default location...")
default_common_path = Path.home() / ".steam/steam/steamapps/common"
if default_common_path.is_dir():
logger.info(f"Using default Steam library common path: {default_common_path}")
return default_common_path
default_common_path_local = Path.home() / ".local/share/Steam/steamapps/common"
if default_common_path_local.is_dir():
logger.info(f"Using default local Steam library common path: {default_common_path_local}")
return default_common_path_local
logger.error("No valid Steam library common path found in VDF or default locations.")
return None
except Exception as e:
logger.error(f"Error parsing libraryfolders.vdf or finding Steam library: {e}", exc_info=True)
return None
@staticmethod
def get_steam_library_path(steam_path: str) -> Optional[str]:
"""Get the Steam library path from libraryfolders.vdf."""
try:
libraryfolders_path = os.path.join(steam_path, 'steamapps', 'libraryfolders.vdf')
if not os.path.exists(libraryfolders_path):
return None
with open(libraryfolders_path, 'r', encoding='utf-8') as f:
content = f.read()
libraries = {}
current_library = None
for line in content.split('\n'):
line = line.strip()
if line.startswith('"path"'):
current_library = line.split('"')[3].replace('\\\\', '\\')
elif line.startswith('"apps"') and current_library:
libraries[current_library] = True
for library_path in libraries:
if os.path.exists(library_path):
return library_path
return None
except Exception as e:
logger.error(f"Error getting Steam library path: {str(e)}")
return None
@staticmethod
def get_mountpoint(path) -> Optional[str]:
"""Return the mount point for the given path (Linux). Used for STEAM_COMPAT_MOUNTS."""
if not path:
return None
try:
p = Path(path).resolve()
if not p.exists():
p = p.parent
while p != p.parent:
if os.path.ismount(p):
return str(p)
p = p.parent
return str(p)
except (OSError, RuntimeError) as e:
logger.debug(f"Could not get mountpoint for {path}: {e}")
return None
def get_steam_compat_mount_paths(self, install_dir=None, download_dir=None) -> List[str]:
"""
Build list of mount paths for STEAM_COMPAT_MOUNTS: other Steam library roots plus
mountpoints of install_dir and download_dir so MO2 can access game and downloads.
"""
seen = set()
result = []
main_steam_lib_path_obj = self.find_steam_library()
if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common":
main_steam_lib_path = main_steam_lib_path_obj.parent.parent
else:
main_steam_lib_path = main_steam_lib_path_obj
main_resolved = str(main_steam_lib_path.resolve()) if main_steam_lib_path else None
for lib_path in self.get_all_steam_library_paths():
try:
r = str(lib_path.resolve())
except (OSError, RuntimeError):
r = str(lib_path)
if r not in seen and r != main_resolved:
seen.add(r)
result.append(r)
for extra in (install_dir, download_dir):
mp = self.get_mountpoint(extra) if extra else None
if mp and mp not in seen:
seen.add(mp)
result.append(mp)
return result
@staticmethod
def get_all_steam_library_paths() -> List[Path]:
"""Finds all Steam library paths listed in all known libraryfolders.vdf files (including Flatpak)."""
logger.info("[DEBUG] Searching for all Steam libraryfolders.vdf files...")
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",
]
library_paths = set()
for vdf_path in vdf_paths:
if vdf_path.is_file():
logger.info(f"[DEBUG] Parsing libraryfolders.vdf: {vdf_path}")
try:
with open(vdf_path, 'r', encoding='utf-8') as f:
data = vdf.load(f)
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'])
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:
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}")
return list(library_paths)
def _find_shortcuts_vdf(self) -> Optional[str]:
"""Helper to find the active shortcuts.vdf file for the current Steam user."""
try:
from jackify.backend.services.native_steam_service import NativeSteamService
steam_service = NativeSteamService()
shortcuts_path = steam_service.get_shortcuts_vdf_path()
if shortcuts_path:
logger.info(f"Found shortcuts.vdf using multi-user detection: {shortcuts_path}")
return str(shortcuts_path)
logger.error("Could not determine shortcuts.vdf path using multi-user detection")
return None
except Exception as e:
logger.error(f"Error using multi-user detection for shortcuts.vdf: {e}")
return None

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,143 @@
"""Progress/speed extraction methods for ProgressParser (Mixin)."""
import logging
import re
from typing import Optional, Tuple
logger = logging.getLogger(__name__)
class ProgressParserExtractionMixin:
"""Mixin providing progress and speed extraction methods."""
def _extract_overall_progress(self, line: str) -> Optional[float]:
"""Extract overall progress percentage."""
match = re.search(r'(?:Progress|Overall):\s*(\d+(?:\.\d+)?)%', line, re.IGNORECASE)
if match:
return float(match.group(1))
match = re.search(r'^(\d+(?:\.\d+)?)%\s*(?:complete|done|progress)', line, re.IGNORECASE)
if match:
return float(match.group(1))
return None
def _extract_step_info(self, line: str) -> Optional[Tuple[int, int]]:
"""Extract step information like [12/14]."""
match = self.wabbajack_status_pattern.search(line)
if match:
current = int(match.group(1))
total = int(match.group(2))
return (current, total)
match = re.search(r'\[(\d+)/(\d+)\]', line)
if match:
current = int(match.group(1))
total = int(match.group(2))
return (current, total)
return None
def _extract_data_info(self, line: str) -> Optional[Tuple[int, int]]:
"""Extract data size information like 1.1GB/56.3GB."""
match = re.search(r'\(?(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\)?', line, re.IGNORECASE)
if match:
current_val = float(match.group(1))
current_unit = match.group(2).upper()
total_val = float(match.group(3))
total_unit = match.group(4).upper()
current_bytes = self._convert_to_bytes(current_val, current_unit)
total_bytes = self._convert_to_bytes(total_val, total_unit)
return (current_bytes, total_bytes)
return None
def _parse_data_string(self, data_str: str) -> Optional[Tuple[int, int]]:
"""Parse data string like '1.1GB/56.3GB' or '1234/5678'."""
match = re.search(r'(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)', data_str, re.IGNORECASE)
if match:
current_val = float(match.group(1))
current_unit = match.group(2).upper()
total_val = float(match.group(3))
total_unit = match.group(4).upper()
current_bytes = self._convert_to_bytes(current_val, current_unit)
total_bytes = self._convert_to_bytes(total_val, total_unit)
return (current_bytes, total_bytes)
match = re.search(r'(\d+)\s*/\s*(\d+)', data_str)
if match:
current = int(match.group(1))
total = int(match.group(2))
return (current, total)
return None
def _extract_speed_info(self, line: str) -> Optional[Tuple[str, float]]:
"""Extract speed information."""
match = re.search(r'-\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s', line, re.IGNORECASE)
if match:
speed_val = float(match.group(1))
speed_unit = match.group(2).upper()
speed_bytes = self._convert_to_bytes(speed_val, speed_unit)
operation = "unknown"
line_lower = line.lower()
if 'download' in line_lower:
operation = "download"
elif 'extract' in line_lower:
operation = "extract"
elif 'validat' in line_lower or 'hash' in line_lower:
operation = "validate"
return (operation, speed_bytes)
match = re.search(r'(?:at|speed:?)\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s', line, re.IGNORECASE)
if match:
speed_val = float(match.group(1))
speed_unit = match.group(2).upper()
speed_bytes = self._convert_to_bytes(speed_val, speed_unit)
operation = "unknown"
line_lower = line.lower()
if 'download' in line_lower:
operation = "download"
elif 'extract' in line_lower:
operation = "extract"
elif 'validat' in line_lower:
operation = "validate"
return (operation, speed_bytes)
return None
def _parse_speed(self, speed_str: str) -> float:
"""Parse speed string to bytes per second."""
match = re.search(r'(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s', speed_str, re.IGNORECASE)
if match:
value = float(match.group(1))
unit = match.group(2).upper()
return self._convert_to_bytes(value, unit)
return 0.0
def _parse_speed_from_string(self, speed_str: str) -> float:
"""Parse speed string like '6.8MB/s' to bytes per second."""
match = re.search(r'(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s(?:ec)?', speed_str, re.IGNORECASE)
if match:
value = float(match.group(1))
unit = match.group(2).upper()
return self._convert_to_bytes(value, unit)
return 0.0
def _convert_to_bytes(self, value: float, unit: str) -> int:
"""Convert value with unit to bytes."""
multipliers = {
'B': 1,
'KB': 1024,
'MB': 1024 * 1024,
'GB': 1024 * 1024 * 1024,
'TB': 1024 * 1024 * 1024 * 1024
}
return int(value * multipliers.get(unit, 1))

View File

@@ -0,0 +1,235 @@
"""File progress parsing methods for ProgressParser (Mixin)."""
import logging
import re
from typing import Optional
from jackify.shared.progress_models import FileProgress, OperationType
logger = logging.getLogger(__name__)
class ProgressParserFilesMixin:
"""Mixin providing file progress parsing methods."""
def _extract_file_progress(self, line: str) -> Optional[FileProgress]:
"""Extract file-level progress information."""
if not line or not isinstance(line, str):
return None
if len(line) > 10000:
return None
if '\x00' in line:
line = line.replace('\x00', '')
file_progress_match = re.search(
r'\[FILE_PROGRESS\]\s+(Downloading|Extracting|Validating|Installing|Converting|Building|Writing|Verifying|Completed|Checking existing):\s+(.+?)\s+\((\d+(?:\.\d+)?)%\)\s*(?:\[(.+?)\])?\s*(?:\((\d+)/(\d+)\))?',
line,
re.IGNORECASE
)
if file_progress_match:
operation_str = file_progress_match.group(1).strip()
filename = file_progress_match.group(2).strip()
percent = float(file_progress_match.group(3))
speed_str = file_progress_match.group(4).strip() if file_progress_match.group(4) else None
counter_current = int(file_progress_match.group(5)) if file_progress_match.group(5) else None
counter_total = int(file_progress_match.group(6)) if file_progress_match.group(6) else None
operation_map = {
'downloading': OperationType.DOWNLOAD,
'extracting': OperationType.EXTRACT,
'validating': OperationType.VALIDATE,
'installing': OperationType.INSTALL,
'building': OperationType.INSTALL,
'writing': OperationType.INSTALL,
'verifying': OperationType.VALIDATE,
'checking existing': OperationType.VALIDATE,
'converting': OperationType.INSTALL,
'compiling': OperationType.INSTALL,
'hashing': OperationType.VALIDATE,
'completed': OperationType.UNKNOWN,
}
operation = operation_map.get(operation_str.lower(), OperationType.UNKNOWN)
if counter_current and counter_total and not self._should_display_file(filename):
file_progress = FileProgress(
filename="__phase_progress__",
operation=operation,
percent=percent,
speed=-1.0
)
file_progress._file_counter = (counter_current, counter_total)
file_progress._hidden = True
return file_progress
if not self._should_display_file(filename):
return None
if operation_str.lower() == 'completed':
percent = 100.0
speed = -1.0
if speed_str:
speed = self._parse_speed_from_string(speed_str)
file_progress = FileProgress(
filename=filename,
operation=operation,
percent=percent,
speed=speed
)
size_info = self._extract_data_info(line)
if size_info:
file_progress.current_size, file_progress.total_size = size_info
if counter_current is not None and counter_total is not None:
if operation_str.lower() == 'converting':
file_progress._texture_counter = (counter_current, counter_total)
elif operation_str.lower() == 'building':
file_progress._bsa_counter = (counter_current, counter_total)
else:
file_progress._file_counter = (counter_current, counter_total)
return file_progress
if re.search(r'\[.*?\]\s*(?:Downloading|Installing|Extracting)\s+(?:Mod|Files|Archives)', line, re.IGNORECASE):
return None
match = re.search(r'(?:Installing|Downloading|Extracting|Validating):\s*(.+?)\s*\((\d+(?:\.\d+)?)%\)', line, re.IGNORECASE)
if match:
filename = match.group(1).strip()
percent = float(match.group(2))
operation = self._detect_operation_from_line(line)
file_progress = FileProgress(
filename=filename,
operation=operation,
percent=percent
)
size_info = self._extract_data_info(line)
if size_info:
file_progress.current_size, file_progress.total_size = size_info
return file_progress
match = re.search(r'(.+?\.(?:7z|zip|rar|bsa|dds|exe|esp|esm|esl|wabbajack))\s*[:-]\s*(\d+(?:\.\d+)?)%', line, re.IGNORECASE)
if match:
filename = match.group(1).strip()
percent = float(match.group(2))
operation = self._detect_operation_from_line(line)
file_progress = FileProgress(
filename=filename,
operation=operation,
percent=percent
)
size_info = self._extract_data_info(line)
if size_info:
file_progress.current_size, file_progress.total_size = size_info
return file_progress
match = re.search(r'(.+?\.(?:7z|zip|rar|bsa|dds|exe|esp|esm|esl|wabbajack))\s*[\[@]\s*([^\]]+)\]?', line, re.IGNORECASE)
if match:
filename = match.group(1).strip()
speed_str = match.group(2).strip().rstrip(']')
speed = self._parse_speed(speed_str)
operation = self._detect_operation_from_line(line)
file_progress = FileProgress(
filename=filename,
operation=operation,
speed=speed
)
size_info = self._extract_data_info(line)
if size_info:
file_progress.current_size, file_progress.total_size = size_info
return file_progress
match = re.search(r'([A-Za-z0-9][^\s]*?[-_A-Za-z0-9]+\.(?:7z|zip|rar|bsa|dds|exe|esp|esm|esl|wabbajack))\s+(?:at|@|:|-)?\s*(\d+(?:\.\d+)?)%', line, re.IGNORECASE)
if match:
filename = match.group(1).strip()
percent = float(match.group(2))
operation = self._detect_operation_from_line(line)
return FileProgress(
filename=filename,
operation=operation,
percent=percent
)
match = re.search(r'([A-Za-z0-9][^\s]*?[-_A-Za-z0-9]+\.(?:7z|zip|rar|bsa|dds|exe|esp|esm|esl|wabbajack))\s*[\(]?\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/?\s*of\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)', line, re.IGNORECASE)
if match:
filename = match.group(1).strip()
current_val = float(match.group(2))
current_unit = match.group(3).upper()
total_val = float(match.group(4))
total_unit = match.group(5).upper()
current_bytes = self._convert_to_bytes(current_val, current_unit)
total_bytes = self._convert_to_bytes(total_val, total_unit)
percent = (current_bytes / total_bytes * 100.0) if total_bytes > 0 else 0.0
operation = self._detect_operation_from_line(line)
return FileProgress(
filename=filename,
operation=operation,
percent=percent,
current_size=current_bytes,
total_size=total_bytes
)
match = re.search(r'([A-Za-z0-9][^\s]*?[-_A-Za-z0-9]+\.(?:7z|zip|rar|bsa|dds|exe|esp|esm|esl|wabbajack))\s+(?:downloading|extracting|validating|installing)\s+at\s+(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)\s*/s', line, re.IGNORECASE)
if match:
filename = match.group(1).strip()
speed_val = float(match.group(2))
speed_unit = match.group(3).upper()
speed = self._convert_to_bytes(speed_val, speed_unit)
operation = self._detect_operation_from_line(line)
return FileProgress(
filename=filename,
operation=operation,
speed=speed
)
return None
def _parse_file_with_percent(self, match: re.Match) -> Optional[FileProgress]:
"""Parse file progress from percentage match."""
filename = match.group(1).strip()
percent = float(match.group(2))
operation = OperationType.UNKNOWN
return FileProgress(
filename=filename,
operation=operation,
percent=percent
)
def _parse_file_with_speed(self, match: re.Match) -> Optional[FileProgress]:
"""Parse file progress from speed match."""
filename = match.group(1).strip()
speed_str = match.group(2).strip()
speed = self._parse_speed(speed_str)
operation = OperationType.UNKNOWN
return FileProgress(
filename=filename,
operation=operation,
speed=speed
)
def _detect_operation_from_line(self, line: str) -> OperationType:
"""Detect operation type from line content."""
line_lower = line.lower()
if 'download' in line_lower:
return OperationType.DOWNLOAD
elif 'extract' in line_lower:
return OperationType.EXTRACT
elif 'validat' in line_lower:
return OperationType.VALIDATE
elif 'install' in line_lower or 'build' in line_lower or 'convert' in line_lower:
return OperationType.INSTALL
else:
return OperationType.UNKNOWN
def _extract_completed_file(self, line: str) -> Optional[str]:
"""Extract filename from completion messages like 'Finished downloading filename.7z'."""
match = re.search(
r'Finished\s+(?:downloading|extracting|validating|installing)\s+(.+?)(?:\.\s|\.$|\s+Hash:)',
line,
re.IGNORECASE
)
if match:
filename = match.group(1).strip()
filename = filename.rstrip('. ')
return filename
return None

View File

@@ -0,0 +1,96 @@
"""Phase extraction methods for ProgressParser (Mixin)."""
import logging
import re
from typing import Optional, Tuple
from jackify.shared.progress_models import InstallationPhase
logger = logging.getLogger(__name__)
class ProgressParserPhaseMixin:
"""Mixin providing phase extraction methods."""
def _extract_phase(self, line: str) -> Optional[Tuple[InstallationPhase, str]]:
"""Extract phase information from line."""
section_match = re.search(r'===?\s*(.+?)\s*===?', line)
if section_match:
section_name = section_match.group(1).strip().lower()
phase = self._map_section_to_phase(section_name)
return (phase, section_match.group(1).strip())
action_match = re.search(
r'\[.*?\]\s*(Installing|Downloading|Extracting|Validating|Processing|Checking existing)',
line,
re.IGNORECASE
)
if action_match:
action = action_match.group(1).lower()
phase = self._map_action_to_phase(action)
return (phase, action_match.group(1))
return None
def _extract_phase_from_section(self, match: re.Match) -> Optional[Tuple[InstallationPhase, str]]:
"""Extract phase from section header match."""
section_name = match.group(1).strip().lower()
phase = self._map_section_to_phase(section_name)
return (phase, match.group(1).strip())
def _extract_phase_from_action(self, match: re.Match) -> Optional[Tuple[InstallationPhase, str]]:
"""Extract phase from action match."""
action = match.group(1).lower()
phase = self._map_action_to_phase(action)
return (phase, match.group(1))
def _map_section_to_phase(self, section_name: str) -> InstallationPhase:
"""Map section name to InstallationPhase enum."""
section_lower = section_name.lower()
if 'download' in section_lower:
return InstallationPhase.DOWNLOAD
elif 'extract' in section_lower:
return InstallationPhase.EXTRACT
elif 'validate' in section_lower or 'verif' in section_lower:
return InstallationPhase.VALIDATE
elif 'install' in section_lower:
return InstallationPhase.INSTALL
elif 'finaliz' in section_lower or 'complet' in section_lower:
return InstallationPhase.FINALIZE
elif 'configur' in section_lower or 'initializ' in section_lower:
return InstallationPhase.INITIALIZATION
else:
return InstallationPhase.UNKNOWN
def _map_action_to_phase(self, action: str) -> InstallationPhase:
"""Map action word to InstallationPhase enum."""
action_lower = action.lower()
if 'download' in action_lower:
return InstallationPhase.DOWNLOAD
elif 'extract' in action_lower:
return InstallationPhase.EXTRACT
elif 'validat' in action_lower or 'checking' in action_lower:
return InstallationPhase.VALIDATE
elif 'install' in action_lower:
return InstallationPhase.INSTALL
else:
return InstallationPhase.UNKNOWN
def _extract_phase_from_text(self, text: str) -> Optional[Tuple[InstallationPhase, str]]:
"""Extract phase from status text like 'Installing files'."""
text_lower = text.lower()
if 'download' in text_lower:
return (InstallationPhase.DOWNLOAD, text)
elif 'extract' in text_lower:
return (InstallationPhase.EXTRACT, text)
elif 'validat' in text_lower or 'hash' in text_lower:
return (InstallationPhase.VALIDATE, text)
elif 'install' in text_lower:
return (InstallationPhase.INSTALL, text)
elif 'prepar' in text_lower or 'configur' in text_lower:
return (InstallationPhase.INITIALIZATION, text)
elif 'finish' in text_lower or 'complet' in text_lower:
return (InstallationPhase.FINALIZE, text)
else:
return (InstallationPhase.UNKNOWN, text)

View File

@@ -0,0 +1,167 @@
"""Metrics and synthetic entry methods for ProgressStateManager (Mixin)."""
import logging
import re
import time
from typing import TYPE_CHECKING
from jackify.shared.progress_models import FileProgress, OperationType, InstallationPhase
if TYPE_CHECKING:
from jackify.backend.handlers.progress_parser import ParsedLine
logger = logging.getLogger(__name__)
class ProgressStateMetricsMixin:
"""Mixin providing metrics augmentation methods."""
def _augment_file_metrics(self, file_progress: FileProgress) -> None:
"""Populate size/speed info to improve UI accuracy."""
now = time.time()
history = self._file_history.get(file_progress.filename)
total_size = file_progress.total_size or (history.get('total') if history else None)
if total_size and file_progress.percent and not file_progress.current_size:
file_progress.current_size = int((file_progress.percent / 100.0) * total_size)
elif file_progress.current_size and not total_size and file_progress.total_size:
total_size = file_progress.total_size
if total_size and not file_progress.total_size:
file_progress.total_size = total_size
current_size = file_progress.current_size or 0
computed_speed = 0.0
if file_progress.speed < 0:
computed_speed = 0.0
if history and current_size:
prev_bytes = history.get('bytes', 0)
prev_time = history.get('time', now)
delta_bytes = current_size - prev_bytes
delta_time = now - prev_time
if delta_bytes >= 0 and delta_time >= 1.0:
computed_speed = delta_bytes / delta_time
elif history.get('computed_speed'):
computed_speed = history.get('computed_speed', 0.0)
file_progress.speed = computed_speed
else:
computed_speed = file_progress.speed
if current_size or total_size:
self._file_history[file_progress.filename] = {
'bytes': current_size,
'time': now,
'total': total_size or (history.get('total') if history else None),
'computed_speed': computed_speed,
}
elif history:
self._file_history[file_progress.filename] = history
def _maybe_add_wabbajack_progress(self, parsed: "ParsedLine") -> bool:
"""Create a synthetic file entry for .wabbajack archive download."""
if not parsed.data_info:
return False
if not parsed.data_info:
return False
current_bytes, total_bytes = parsed.data_info
if total_bytes <= 0:
return False
for fp in self.state.active_files:
if fp.filename.lower().endswith('.wabbajack'):
synthetic_entry = fp
if getattr(fp, self._synthetic_flag, False):
percent = (current_bytes / total_bytes) * 100.0
synthetic_entry.percent = percent
synthetic_entry.current_size = current_bytes
synthetic_entry.total_size = total_bytes
synthetic_entry.last_update = time.time()
self._augment_file_metrics(synthetic_entry)
return True
else:
return False
synthetic_entry = None
for fp in self.state.active_files:
if getattr(fp, self._synthetic_flag, False):
synthetic_entry = fp
break
message = (parsed.message or "")
phase_name = (parsed.phase_name or "").lower()
should_force = 'wabbajack' in message.lower() or 'wabbajack' in phase_name
if not synthetic_entry:
if self._has_real_download_activity() and not should_force:
return False
if self.state.phase not in (InstallationPhase.INITIALIZATION, InstallationPhase.DOWNLOAD) and not should_force:
return False
percent = (current_bytes / total_bytes) * 100.0
if not self._wabbajack_entry_name:
filename_match = re.search(r'([A-Za-z0-9_\-\.]+\.wabbajack)', message, re.IGNORECASE)
if filename_match:
self._wabbajack_entry_name = filename_match.group(1)
if not self._wabbajack_entry_name:
self._wabbajack_entry_name = "Downloading .wabbajack file"
entry_name = self._wabbajack_entry_name
if synthetic_entry:
synthetic_entry.percent = percent
synthetic_entry.current_size = current_bytes
synthetic_entry.total_size = total_bytes
synthetic_entry.last_update = time.time()
self._augment_file_metrics(synthetic_entry)
else:
special_file = FileProgress(
filename=entry_name,
operation=OperationType.DOWNLOAD,
percent=percent,
current_size=current_bytes,
total_size=total_bytes
)
special_file.last_update = time.time()
setattr(special_file, self._synthetic_flag, True)
self._augment_file_metrics(special_file)
self.state.add_file(special_file)
return True
def _has_real_download_activity(self) -> bool:
"""Check if there are real download entries already visible."""
for fp in self.state.active_files:
if getattr(fp, self._synthetic_flag, False):
continue
if fp.operation == OperationType.DOWNLOAD:
return True
return False
def _remove_synthetic_wabbajack(self) -> None:
"""Remove any synthetic .wabbajack entries once real files appear."""
remaining = []
removed = False
for fp in self.state.active_files:
if getattr(fp, self._synthetic_flag, False):
removed = True
self._file_history.pop(fp.filename, None)
continue
remaining.append(fp)
if removed:
self.state.active_files = remaining
def _remove_all_wabbajack_entries(self) -> None:
"""Remove ALL .wabbajack entries when archive download phase starts."""
remaining = []
removed = False
for fp in self.state.active_files:
if fp.filename.lower().endswith('.wabbajack') or 'wabbajack' in fp.filename.lower():
removed = True
self._file_history.pop(fp.filename, None)
continue
remaining.append(fp)
if removed:
self.state.active_files = remaining
self._wabbajack_entry_name = None

View File

@@ -0,0 +1,239 @@
"""Line processing methods for ProgressStateManager (Mixin)."""
import logging
import time
from typing import TYPE_CHECKING
from jackify.shared.progress_models import (
InstallationPhase,
InstallationProgress,
FileProgress,
OperationType,
)
if TYPE_CHECKING:
from jackify.backend.handlers.progress_parser import ParsedLine
logger = logging.getLogger(__name__)
class ProgressStateProcessingMixin:
"""Mixin providing line processing methods."""
def process_line(self, line: str) -> bool:
"""
Process a line of output and update state.
Returns:
True if state was updated, False otherwise
"""
parsed = self.parser.parse_line(line)
if not parsed.has_progress:
return False
updated = False
phase_changed = False
if parsed.phase and parsed.phase != self.state.phase:
previous_phase = self.state.phase
if previous_phase == InstallationPhase.DOWNLOAD:
self._download_files_seen = {}
self._download_total_bytes = 0
self._download_processed_bytes = 0
if previous_phase == InstallationPhase.VALIDATE and not parsed.data_info:
if self.state.data_total > 0:
self.state.data_processed = 0
self.state.data_total = 0
updated = True
if previous_phase == InstallationPhase.VALIDATE:
if self.state.phase_name and 'validat' in self.state.phase_name.lower():
self.state.phase_name = ""
updated = True
phase_changed = True
self._previous_phase = self.state.phase
self.state.phase = parsed.phase
updated = True
elif parsed.phase:
self.state.phase = parsed.phase
updated = True
if parsed.phase_name:
self.state.phase_name = parsed.phase_name
updated = True
elif phase_changed:
if self.state.phase_name and self.state.phase != InstallationPhase.VALIDATE:
self.state.phase_name = ""
updated = True
if self.state.phase == InstallationPhase.DOWNLOAD:
if self.state.phase_name and 'validat' in self.state.phase_name.lower():
self.state.phase_name = ""
updated = True
if parsed.overall_percent is not None:
self.state.overall_percent = parsed.overall_percent
updated = True
if parsed.step_info:
self.state.phase_step, self.state.phase_max_steps = parsed.step_info
updated = True
if parsed.data_info:
self.state.data_processed, self.state.data_total = parsed.data_info
if self.state.data_total > 0 and self.state.overall_percent == 0.0:
self.state.overall_percent = (self.state.data_processed / self.state.data_total) * 100.0
updated = True
if parsed.file_counter:
self.state.phase_step, self.state.phase_max_steps = parsed.file_counter
updated = True
if parsed.file_progress:
if hasattr(parsed.file_progress, '_hidden') and parsed.file_progress._hidden:
return updated
if hasattr(parsed.file_progress, '_texture_counter'):
tex_current, tex_total = parsed.file_progress._texture_counter
self.state.texture_conversion_current = tex_current
self.state.texture_conversion_total = tex_total
updated = True
if hasattr(parsed.file_progress, '_bsa_counter'):
bsa_current, bsa_total = parsed.file_progress._bsa_counter
self.state.bsa_building_current = bsa_current
self.state.bsa_building_total = bsa_total
updated = True
if parsed.file_progress.filename.lower().endswith('.wabbajack'):
self._wabbajack_entry_name = parsed.file_progress.filename
self._remove_synthetic_wabbajack()
self._has_real_wabbajack = True
else:
if parsed.file_progress.operation == OperationType.DOWNLOAD:
self._remove_all_wabbajack_entries()
self._has_real_wabbajack = True
if self.state.phase == InstallationPhase.DOWNLOAD and parsed.file_progress.operation == OperationType.DOWNLOAD:
filename = parsed.file_progress.filename
total_size = parsed.file_progress.total_size or 0
current_size = parsed.file_progress.current_size or 0
if filename not in self._download_files_seen:
if total_size > 0:
self._download_total_bytes += total_size
self._download_files_seen[filename] = (total_size, current_size)
self._download_processed_bytes += current_size
else:
old_total, old_current = self._download_files_seen[filename]
if total_size > old_total:
self._download_total_bytes += (total_size - old_total)
if current_size > old_current:
self._download_processed_bytes += (current_size - old_current)
self._download_files_seen[filename] = (max(old_total, total_size), current_size)
if self.state.data_total == 0 and self._download_total_bytes > 0:
self.state.data_total = self._download_total_bytes
self.state.data_processed = self._download_processed_bytes
updated = True
self._augment_file_metrics(parsed.file_progress)
existing_file = None
for f in self.state.active_files:
if f.filename == parsed.file_progress.filename:
existing_file = f
break
if parsed.file_progress.percent >= 100.0 and not existing_file:
updated = True
elif parsed.file_progress.percent >= 100.0:
parsed.file_progress.percent = 100.0
parsed.file_progress.last_update = time.time()
self.state.add_file(parsed.file_progress)
updated = True
else:
self.state.add_file(parsed.file_progress)
updated = True
elif parsed.data_info:
phase_name_lower = (parsed.phase_name or "").lower()
message_lower = (parsed.message or "").lower()
is_archive_phase = (
'mod archives' in phase_name_lower or
'downloading mod archives' in message_lower or
(parsed.phase == InstallationPhase.DOWNLOAD and self._has_real_download_activity())
)
if is_archive_phase:
self._remove_all_wabbajack_entries()
self._has_real_wabbajack = True
if not getattr(self, '_has_real_wabbajack', False):
if self._maybe_add_wabbajack_progress(parsed):
updated = True
if parsed.completed_filename:
if not self.parser.should_display_file(parsed.completed_filename):
parsed.completed_filename = None
if parsed.completed_filename:
if self.state.phase == InstallationPhase.DOWNLOAD:
filename = parsed.completed_filename
if filename in self._download_files_seen:
old_total, old_current = self._download_files_seen[filename]
if old_current < old_total:
self._download_processed_bytes += (old_total - old_current)
self._download_files_seen[filename] = (old_total, old_total)
if self.state.data_total == 0 and self._download_total_bytes > 0:
self.state.data_total = self._download_total_bytes
self.state.data_processed = self._download_processed_bytes
updated = True
found_existing = False
for file_prog in self.state.active_files:
filename_match = (
file_prog.filename == parsed.completed_filename or
file_prog.filename.endswith(parsed.completed_filename) or
parsed.completed_filename in file_prog.filename
)
if filename_match:
file_prog.percent = 100.0
file_prog.last_update = time.time()
updated = True
found_existing = True
break
if not found_existing:
operation = OperationType.DOWNLOAD
if parsed.file_progress:
operation = parsed.file_progress.operation
completed_file = FileProgress(
filename=parsed.completed_filename,
operation=operation,
percent=100.0,
current_size=0,
total_size=0
)
completed_file.last_update = time.time()
self.state.add_file(completed_file)
updated = True
if parsed.speed_info:
operation, speed = parsed.speed_info
self.state.update_speed(operation, speed)
updated = True
if parsed.message:
self.state.message = parsed.message
if updated:
self.state.timestamp = time.time()
if updated:
self.state.remove_completed_files()
return updated

View File

@@ -0,0 +1,147 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Protontricks run/launch commands mixin.
Extracted from protontricks_handler for file-size and domain separation.
"""
import os
import subprocess
from pathlib import Path
import shutil
from typing import Optional
import logging
logger = logging.getLogger(__name__)
class ProtontricksCommandsMixin:
"""Mixin providing run_protontricks and run_protontricks_launch."""
def run_protontricks(self, *args, **kwargs):
"""
Run protontricks with the given arguments and keyword arguments.
kwargs are passed to subprocess.run (e.g., stderr=subprocess.DEVNULL).
Returns subprocess.CompletedProcess or None.
"""
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 == 'bundled':
from .subprocess_utils import get_safe_python_executable
python_exe = get_safe_python_executable()
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:
cmd = [python_exe, "-m", "protontricks.cli.main"]
cmd.extend([str(a) for a in args])
elif self.which_protontricks == 'flatpak':
cmd = list(self._get_flatpak_run_args())
if kwargs.get('env') and kwargs['env'].get('WINETRICKS_CACHE'):
try:
cache_val = str(Path(kwargs['env']['WINETRICKS_CACHE']).resolve())
cmd.append(f'--env=WINETRICKS_CACHE={cache_val}')
except Exception:
pass
cmd.append("com.github.Matoking.protontricks")
cmd.extend(args)
else:
cmd = ["protontricks"]
cmd.extend(args)
run_kwargs = {
'stdout': subprocess.PIPE,
'stderr': subprocess.PIPE,
'text': True,
**kwargs
}
cmd_str = ' '.join(map(str, cmd))
self.logger.debug("=" * 80)
self.logger.debug("PROTONTRICKS COMMAND (for manual reproduction):")
self.logger.debug(f" {cmd_str}")
self.logger.debug("=" * 80)
if 'env' in kwargs and kwargs['env']:
env = self._get_clean_subprocess_env()
env.update(kwargs['env'])
else:
env = self._get_clean_subprocess_env()
env['WINEDEBUG'] = '-all'
steam_dir = self._get_steam_dir_from_libraryfolders()
if steam_dir:
env['STEAM_DIR'] = str(steam_dir)
self.logger.debug(f"Set STEAM_DIR for protontricks: {steam_dir}")
else:
self.logger.warning("Could not determine STEAM_DIR from libraryfolders.vdf - protontricks may prompt user")
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:
self.logger.debug(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)")
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 to suppress winetricks verbose output")
else:
self.logger.debug("Debug mode enabled - winetricks verbose output will be shown")
run_kwargs['env'] = env
try:
return subprocess.run(cmd, **run_kwargs)
except Exception as e:
self.logger.error(f"Error running protontricks: {e}")
return None
def run_protontricks_launch(self, appid, installer_path, *extra_args):
"""
Run protontricks-launch (for WebView or similar installers).
Returns subprocess.CompletedProcess or None.
"""
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 == 'bundled':
from .subprocess_utils import get_safe_python_executable
python_exe = get_safe_python_executable()
cmd = [python_exe, "-m", "protontricks.cli.launch", "--appid", appid, str(installer_path)]
elif self.which_protontricks == 'flatpak':
cmd = self._get_flatpak_run_args() + ["--command=protontricks-launch", "com.github.Matoking.protontricks", "--appid", appid, str(installer_path)]
else:
launch_path = shutil.which("protontricks-launch")
if not launch_path:
self.logger.error("protontricks-launch command not found in PATH.")
return None
cmd = [launch_path, "--appid", appid, str(installer_path)]
if extra_args:
cmd.extend(extra_args)
self.logger.debug(f"Running protontricks-launch: {' '.join(map(str, cmd))}")
try:
env = self._get_clean_subprocess_env()
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env)
except Exception as e:
self.logger.error(f"Error running protontricks-launch: {e}")
return None

View File

@@ -0,0 +1,195 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Protontricks detection and version mixin.
Extracted from protontricks_handler for file-size and domain separation.
"""
import os
import re
import subprocess
from pathlib import Path
import shutil
import logging
from typing import Optional, List
import sys
from .subprocess_utils import get_clean_subprocess_env
class ProtontricksDetectionMixin:
"""Mixin providing protontricks detection, Steam dir, bundled paths, and version checks."""
def _get_steam_dir_from_libraryfolders(self) -> Optional[Path]:
"""Determine Steam installation directory from libraryfolders.vdf."""
from ..handlers.path_handler import PathHandler
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",
Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam/config/libraryfolders.vdf",
]
for vdf_path in vdf_paths:
if vdf_path.is_file():
steam_dir = vdf_path.parent.parent
if (steam_dir / "steamapps").exists():
self.logger.debug(f"Determined STEAM_DIR from libraryfolders.vdf: {steam_dir}")
return steam_dir
library_paths = PathHandler.get_all_steam_library_paths()
if library_paths:
first_lib = library_paths[0]
if '.var/app/com.valvesoftware.Steam' in str(first_lib):
data_steam = Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam"
if (data_steam / "steamapps").exists():
self.logger.debug(f"Determined STEAM_DIR from Flatpak data path: {data_steam}")
return data_steam
if (first_lib / "steamapps").exists():
self.logger.debug(f"Determined STEAM_DIR from Flatpak library path: {first_lib}")
return first_lib
elif (first_lib / "steamapps").exists():
self.logger.debug(f"Determined STEAM_DIR from native library path: {first_lib}")
return first_lib
self.logger.warning("Could not determine STEAM_DIR from libraryfolders.vdf")
return None
def _get_bundled_winetricks_path(self) -> Optional[Path]:
"""Get path to bundled winetricks (AppImage and dev)."""
possible_paths = []
if os.environ.get('APPDIR'):
possible_paths.append(Path(os.environ['APPDIR']) / 'opt' / 'jackify' / 'tools' / 'winetricks')
module_dir = Path(__file__).parent.parent.parent
possible_paths.append(module_dir / 'tools' / 'winetricks')
for path in possible_paths:
if path.exists() and os.access(path, os.X_OK):
self.logger.debug(f"Found bundled winetricks at: {path}")
return path
self.logger.warning(f"Bundled winetricks not found. Tried paths: {possible_paths}")
return None
def _get_bundled_cabextract_path(self) -> Optional[Path]:
"""Get path to bundled cabextract (AppImage and dev)."""
possible_paths = []
if os.environ.get('APPDIR'):
possible_paths.append(Path(os.environ['APPDIR']) / 'opt' / 'jackify' / 'tools' / 'cabextract')
module_dir = Path(__file__).parent.parent.parent
possible_paths.append(module_dir / 'tools' / 'cabextract')
for path in possible_paths:
if path.exists() and os.access(path, os.X_OK):
self.logger.debug(f"Found bundled cabextract at: {path}")
return path
self.logger.warning(f"Bundled cabextract not found. Tried paths: {possible_paths}")
return None
def _get_bundled_protontricks_wrapper_path(self) -> Optional[str]:
"""Return path to bundled protontricks wrapper script if any. Returns None to use python -m fallback."""
return None
def _get_clean_subprocess_env(self):
"""Create clean environment for subprocess (remove AppImage/bundle vars)."""
env = get_clean_subprocess_env()
if 'LD_LIBRARY_PATH_ORIG' in env:
env['LD_LIBRARY_PATH'] = env['LD_LIBRARY_PATH_ORIG']
else:
env.pop('LD_LIBRARY_PATH', None)
if 'DYLD_LIBRARY_PATH' in env and hasattr(sys, '_MEIPASS'):
dyld_entries = env['DYLD_LIBRARY_PATH'].split(os.pathsep)
cleaned_dyld = [p for p in dyld_entries if not p.startswith(sys._MEIPASS)]
if cleaned_dyld:
env['DYLD_LIBRARY_PATH'] = os.pathsep.join(cleaned_dyld)
else:
env.pop('DYLD_LIBRARY_PATH', None)
return env
def _get_native_steam_service(self):
"""Get native Steam operations service instance."""
if self._native_steam_service is None:
from ..services.native_steam_operations_service import NativeSteamOperationsService
self._native_steam_service = NativeSteamOperationsService(steamdeck=self.steamdeck)
return self._native_steam_service
def detect_protontricks(self):
"""Detect if protontricks is installed (native or flatpak). Returns True if found."""
self.logger.debug("Detecting if protontricks is installed...")
protontricks_path_which = shutil.which("protontricks")
self.flatpak_path = shutil.which("flatpak")
if protontricks_path_which:
try:
with open(protontricks_path_which, 'r') as f:
content = f.read()
if "flatpak run" in content:
self.logger.debug(f"Detected Protontricks is a Flatpak wrapper at {protontricks_path_which}")
self.which_protontricks = 'flatpak'
else:
self.logger.info(f"Native Protontricks found at {protontricks_path_which}")
self.which_protontricks = 'native'
self.protontricks_path = protontricks_path_which
return True
except Exception as e:
self.logger.error(f"Error reading protontricks executable: {e}")
try:
env = self._get_clean_subprocess_env()
result_user = subprocess.run(
["flatpak", "list", "--user"],
capture_output=True, text=True, env=env
)
if result_user.returncode == 0 and "com.github.Matoking.protontricks" in result_user.stdout:
self.logger.info("Flatpak Protontricks is installed (user-level)")
self.which_protontricks = 'flatpak'
self.flatpak_install_type = 'user'
return True
result_system = subprocess.run(
["flatpak", "list", "--system"],
capture_output=True, text=True, env=env
)
if result_system.returncode == 0 and "com.github.Matoking.protontricks" in result_system.stdout:
self.logger.info("Flatpak Protontricks is installed (system-level)")
self.which_protontricks = 'flatpak'
self.flatpak_install_type = 'system'
return True
except FileNotFoundError:
self.logger.warning("'flatpak' command not found. Cannot check for Flatpak Protontricks.")
except Exception as e:
self.logger.error(f"Unexpected error checking flatpak: {e}")
self.logger.warning("Protontricks not found (native or flatpak).")
return False
def _get_flatpak_run_args(self) -> List[str]:
"""Get flatpak run arguments (--user or --system)."""
base_args = ["flatpak", "run"]
if self.flatpak_install_type == 'user':
base_args.append("--user")
elif self.flatpak_install_type == 'system':
base_args.append("--system")
return base_args
def _get_flatpak_alias_string(self, command=None) -> str:
"""Get flatpak alias string for bashrc."""
flag = f"--{self.flatpak_install_type}" if self.flatpak_install_type else ""
if command:
return f"flatpak run {flag} --command={command} com.github.Matoking.protontricks" if flag else f"flatpak run --command={command} com.github.Matoking.protontricks"
return f"flatpak run {flag} com.github.Matoking.protontricks" if flag else "flatpak run com.github.Matoking.protontricks"
def check_protontricks_version(self):
"""Check if protontricks version is sufficient (>= 1.12). Returns True if OK."""
try:
if self.which_protontricks == 'flatpak':
cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks", "-V"]
else:
cmd = ["protontricks", "-V"]
result = subprocess.run(cmd, capture_output=True, text=True)
version_str = result.stdout.split(' ')[1].strip('()')
cleaned_version = re.sub(r'[^0-9.]', '', version_str)
self.protontricks_version = cleaned_version
version_parts = cleaned_version.split('.')
if len(version_parts) >= 2:
major, minor = int(version_parts[0]), int(version_parts[1])
if major < 1 or (major == 1 and minor < 12):
self.logger.error(f"Protontricks version {cleaned_version} is too old. Version 1.12.0 or newer is required.")
return False
return True
self.logger.error(f"Could not parse protontricks version: {cleaned_version}")
return False
except Exception as e:
self.logger.error(f"Error checking protontricks version: {e}")
return False

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,271 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Protontricks prefix/Wine component mixin.
Extracted from protontricks_handler for file-size and domain separation.
"""
import os
import subprocess
from pathlib import Path
from typing import Optional, List
import logging
logger = logging.getLogger(__name__)
class ProtontricksPrefixMixin:
"""Mixin for Wine prefix operations: dotfiles, win10, prefix path, component install/verify."""
def enable_dotfiles(self, appid):
"""Enable visibility of (.)dot files in the Wine prefix. Returns True on success."""
self.logger.debug(f"APPID={appid}")
self.logger.info("Enabling visibility of (.)dot files...")
try:
result = self.run_protontricks(
"-c", "WINEDEBUG=-all wine reg query \"HKEY_CURRENT_USER\\Software\\Wine\" /v ShowDotFiles",
appid,
stderr=subprocess.DEVNULL
)
if result and result.returncode == 0 and "ShowDotFiles" in result.stdout and "Y" in result.stdout:
self.logger.info("DotFiles already enabled via registry... skipping")
return True
elif result and result.returncode != 0:
self.logger.info(f"Initial query for ShowDotFiles likely failed (Exit Code: {result.returncode}). Proceeding to set it. Stderr: {result.stderr}")
elif not result:
self.logger.error("Failed to execute initial dotfile query command.")
dotfiles_set_success = False
self.logger.debug("Attempting to set ShowDotFiles registry key...")
result_add = self.run_protontricks(
"-c", "WINEDEBUG=-all wine reg add \"HKEY_CURRENT_USER\\Software\\Wine\" /v ShowDotFiles /d Y /f",
appid,
)
if result_add and result_add.returncode == 0:
self.logger.info("'wine reg add' command executed successfully.")
dotfiles_set_success = True
elif result_add:
self.logger.warning(f"'wine reg add' command failed (Exit Code: {result_add.returncode}). Stderr: {result_add.stderr}")
else:
self.logger.error("Failed to execute 'wine reg add' command.")
self.logger.debug("Ensuring user.reg has correct entry...")
prefix_path = self.get_wine_prefix_path(appid)
if prefix_path:
user_reg_path = Path(prefix_path) / "user.reg"
try:
if user_reg_path.exists():
content = user_reg_path.read_text(encoding='utf-8', errors='ignore')
has_correct_format = '[Software\\\\Wine]' in content and '"ShowDotFiles"="Y"' in content
has_broken_format = '[SoftwareWine]' in content and '"ShowDotFiles"="Y"' in content
if has_broken_format and not has_correct_format:
self.logger.debug(f"Found broken ShowDotFiles format in {user_reg_path}, fixing...")
content = content.replace('[SoftwareWine]', '[Software\\\\Wine]')
user_reg_path.write_text(content, encoding='utf-8')
dotfiles_set_success = True
elif not has_correct_format:
self.logger.debug(f"Adding ShowDotFiles entry to {user_reg_path}")
with open(user_reg_path, 'a', encoding='utf-8') as f:
f.write('\n[Software\\\\Wine] 1603891765\n')
f.write('"ShowDotFiles"="Y"\n')
dotfiles_set_success = True
else:
self.logger.debug("ShowDotFiles already present in correct format in user.reg")
dotfiles_set_success = True
else:
self.logger.warning(f"user.reg not found at {user_reg_path}, creating it.")
with open(user_reg_path, 'w', encoding='utf-8') as f:
f.write('[Software\\\\Wine] 1603891765\n')
f.write('"ShowDotFiles"="Y"\n')
dotfiles_set_success = True
except Exception as e:
self.logger.warning(f"Error reading/writing user.reg: {e}")
else:
self.logger.warning("Could not get WINEPREFIX path, skipping user.reg modification.")
self.logger.debug("Verifying dotfile setting after attempts...")
verify_result = self.run_protontricks(
"-c", "WINEDEBUG=-all wine reg query \"HKEY_CURRENT_USER\\Software\\Wine\" /v ShowDotFiles",
appid,
stderr=subprocess.DEVNULL
)
query_verified = False
if verify_result and verify_result.returncode == 0 and "ShowDotFiles" in verify_result.stdout and "Y" in verify_result.stdout:
self.logger.debug("Verification query successful and key is set.")
query_verified = True
elif verify_result:
self.logger.info(f"Verification query failed or key not found (Exit Code: {verify_result.returncode}). Stderr: {verify_result.stderr}")
else:
self.logger.error("Failed to execute verification query command.")
if dotfiles_set_success:
if query_verified:
self.logger.info("Dotfiles enabled and verified successfully!")
else:
self.logger.info("Dotfiles potentially enabled (reg add/user.reg succeeded), but verification query failed.")
return True
self.logger.error("Failed to enable dotfiles using registry and user.reg methods.")
return False
except Exception as e:
self.logger.error(f"Unexpected error enabling dotfiles: {e}", exc_info=True)
return False
def set_win10_prefix(self, appid):
"""Set Windows 10 version in the proton prefix. Returns True on success."""
try:
env = self._get_clean_subprocess_env()
env["WINEDEBUG"] = "-all"
if self.which_protontricks == 'flatpak':
cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks", "--no-bwrap", appid, "win10"]
else:
cmd = ["protontricks", "--no-bwrap", appid, "win10"]
subprocess.run(cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
except Exception as e:
self.logger.error(f"Error setting Windows 10 prefix: {e}")
return False
def get_wine_prefix_path(self, appid) -> Optional[str]:
"""
Get the WINEPREFIX path for a given AppID.
Uses native path discovery when enabled, else protontricks -c echo $WINEPREFIX.
"""
if self.use_native_operations:
self.logger.debug(f"Getting WINEPREFIX for AppID {appid} via native path discovery")
try:
return self._get_native_steam_service().get_wine_prefix_path(appid)
except Exception as e:
self.logger.warning(f"Native WINEPREFIX detection failed, falling back to protontricks: {e}")
self.logger.debug(f"Getting WINEPREFIX for AppID {appid}")
result = self.run_protontricks("-c", "echo $WINEPREFIX", appid)
if result and result.returncode == 0 and result.stdout.strip():
prefix_path = result.stdout.strip()
self.logger.debug(f"Detected WINEPREFIX: {prefix_path}")
return prefix_path
self.logger.error(f"Failed to get WINEPREFIX for AppID {appid}. Stderr: {result.stderr if result else 'N/A'}")
return None
def install_wine_components(self, appid, game_var, specific_components: Optional[List[str]] = None):
"""
Install Wine components into the prefix using protontricks.
If specific_components is None, use default set (fontsmooth=rgb, xact, xact_x64, vcrun2022).
"""
self.logger.info("=" * 80)
self.logger.info("USING PROTONTRICKS")
self.logger.info("=" * 80)
env = self._get_clean_subprocess_env()
env["WINEDEBUG"] = "-all"
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:
self.logger.info(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)")
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")
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)
self._ensure_flatpak_cache_access(jackify_cache_dir)
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}")
else:
components_to_install = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"]
self.logger.info(f"Installing default components: {components_to_install}")
if not components_to_install:
self.logger.info("No Wine components to install.")
return True
self.logger.info(f"AppID: {appid}, Game: {game_var}, Components: {components_to_install}")
max_attempts = 3
for attempt in range(1, max_attempts + 1):
if attempt > 1:
self.logger.warning(f"Retrying component installation (attempt {attempt}/{max_attempts})...")
self._cleanup_wine_processes()
try:
result = self.run_protontricks("--no-bwrap", appid, "-q", *components_to_install, env=env, timeout=600)
self.logger.debug(f"Protontricks output: {result.stdout if result else ''}")
if result and result.returncode == 0:
self.logger.info("Wine Component installation command completed.")
if self._verify_components_installed(appid, components_to_install):
self.logger.info("Component verification successful - all components installed correctly.")
return True
self.logger.error(f"Component verification failed (Attempt {attempt}/{max_attempts})")
else:
self.logger.error(f"Protontricks command failed (Attempt {attempt}/{max_attempts}). Return Code: {result.returncode if result else 'N/A'}")
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 ''}")
elif result and result.stderr:
stderr_lower = result.stderr.lower()
if any(k in stderr_lower for k in ['error', 'failed', 'cannot', 'warning: cannot find']):
error_lines = [line for line in result.stderr.strip().split('\n')
if any(k in line.lower() for k 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.")
return False
def _verify_components_installed(self, appid: str, components: List[str]) -> bool:
"""Verify every requested component is present in protontricks list-installed."""
try:
self.logger.info("Verifying installed components...")
result = self.run_protontricks("--no-bwrap", appid, "list-installed", timeout=30)
if not result or result.returncode != 0:
self.logger.error("Failed to query installed components")
self.logger.debug(f"list-installed stderr: {result.stderr if result else 'N/A'}")
return False
installed_output = result.stdout.lower()
self.logger.debug(f"Installed components output: {installed_output}")
missing = []
for component in components:
base_component = component.split('=')[0].lower()
if base_component in installed_output or component.lower() in installed_output:
continue
missing.append(component)
if missing:
self.logger.error(f"Components not in list-installed: {missing}")
return False
self.logger.info("Verification passed - all components in list-installed")
return True
except Exception as e:
self.logger.error(f"Error verifying components: {e}", exc_info=True)
return False
def _cleanup_wine_processes(self):
"""Clean up wine-related processes during component installation."""
try:
subprocess.run("pgrep -f 'win7|win10|ShowDotFiles|protontricks' | xargs -r kill -9",
shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run("pkill -9 winetricks",
shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception as e:
self.logger.error(f"Error cleaning up wine processes: {e}")

View File

@@ -0,0 +1,267 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Protontricks Steam/permissions/shortcuts/alias mixin.
Extracted from protontricks_handler for file-size and domain separation.
"""
import os
import re
import subprocess
from pathlib import Path
from typing import Dict
import logging
logger = logging.getLogger(__name__)
class ProtontricksSteamMixin:
"""Mixin for Steam permissions, aliases, and non-Steam shortcut listing."""
def set_protontricks_permissions(self, modlist_dir, steamdeck=False):
"""
Set permissions for Steam operations to access the modlist directory.
Uses native operations when enabled, else protontricks flatpak overrides.
Returns True on success, False on failure.
"""
if self.use_native_operations:
self.logger.debug("Using native Steam operations, permissions handled natively")
try:
return self._get_native_steam_service().set_steam_permissions(modlist_dir, steamdeck)
except Exception as e:
self.logger.warning(f"Native permissions failed, falling back to protontricks: {e}")
if self.which_protontricks != 'flatpak':
self.logger.debug("Using Native protontricks, skip setting permissions")
return True
self.logger.info("Setting Protontricks permissions...")
env = self._get_clean_subprocess_env()
permissions_set = []
permissions_failed = []
try:
self.logger.debug(f"Setting permission for modlist directory: {modlist_dir}")
try:
subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks",
f"--filesystem={modlist_dir}"], check=True, env=env, capture_output=True)
permissions_set.append(f"modlist directory: {modlist_dir}")
except subprocess.CalledProcessError as e:
permissions_failed.append(f"modlist directory: {modlist_dir} ({e})")
self.logger.warning(f"Failed to set permission for modlist directory: {e}")
steam_dir = self._get_steam_dir_from_libraryfolders()
if steam_dir and steam_dir.exists():
self.logger.info(f"Setting permission for Steam directory: {steam_dir}")
self.logger.debug("Allows protontricks to access Steam compatdata, config, steamapps")
try:
subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks",
f"--filesystem={steam_dir}"], check=True, env=env, capture_output=True)
permissions_set.append(f"Steam directory: {steam_dir}")
except subprocess.CalledProcessError as e:
permissions_failed.append(f"Steam directory: {steam_dir} ({e})")
self.logger.warning(f"Failed to set permission for Steam directory: {e}")
else:
self.logger.warning("Could not determine Steam directory - protontricks may not have access to Steam directories")
from ..handlers.path_handler import PathHandler
all_library_paths = PathHandler.get_all_steam_library_paths()
for lib_path in all_library_paths:
if steam_dir and lib_path.resolve() == steam_dir.resolve():
continue
if lib_path.exists():
self.logger.debug(f"Setting permission for Steam library folder: {lib_path}")
try:
subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks",
f"--filesystem={lib_path}"], check=True, env=env, capture_output=True)
permissions_set.append(f"Steam library: {lib_path}")
except subprocess.CalledProcessError as e:
permissions_failed.append(f"Steam library: {lib_path} ({e})")
self.logger.warning(f"Failed to set permission for Steam library folder {lib_path}: {e}")
if steamdeck:
self.logger.warning("Checking for SDCard and setting permissions appropriately...")
result = subprocess.run(["df", "-h"], capture_output=True, text=True, env=env)
for line in result.stdout.splitlines():
if "/run/media" in line:
sdcard_path = line.split()[-1]
self.logger.debug(f"SDCard path: {sdcard_path}")
try:
subprocess.run(["flatpak", "override", "--user", f"--filesystem={sdcard_path}",
"com.github.Matoking.protontricks"], check=True, env=env, capture_output=True)
permissions_set.append(f"SD card: {sdcard_path}")
except subprocess.CalledProcessError as e:
permissions_failed.append(f"SD card: {sdcard_path} ({e})")
self.logger.warning(f"Failed to set permission for SD card {sdcard_path}: {e}")
try:
subprocess.run(["flatpak", "override", "--user", "--filesystem=/run/media/mmcblk0p1",
"com.github.Matoking.protontricks"], check=True, env=env, capture_output=True)
permissions_set.append("SD card: /run/media/mmcblk0p1")
except subprocess.CalledProcessError as e:
self.logger.debug(f"Could not set permission for fallback SD card path (may not exist): {e}")
if permissions_set:
self.logger.info(f"Successfully set {len(permissions_set)} permission(s) for protontricks")
self.logger.debug(f"Permissions set: {', '.join(permissions_set)}")
if permissions_failed:
self.logger.warning(f"Failed to set {len(permissions_failed)} permission(s)")
self.logger.debug(f"Failed permissions: {', '.join(permissions_failed)}")
if any("modlist directory" in p for p in permissions_set):
self.logger.info("Protontricks permissions configured (at least modlist directory access granted)")
return True
self.logger.error("Failed to set critical modlist directory permission")
return False
except Exception as e:
self.logger.error(f"Unexpected error while setting Protontricks permissions: {e}")
return False
def create_protontricks_alias(self):
"""Create aliases for protontricks in ~/.bashrc if using flatpak. Returns True if created or already exists."""
if self.which_protontricks != 'flatpak':
self.logger.debug("Not using flatpak, skipping alias creation")
return True
try:
bashrc_path = os.path.expanduser("~/.bashrc")
if os.path.exists(bashrc_path):
with open(bashrc_path, 'r') as f:
content = f.read()
protontricks_alias_exists = "alias protontricks=" in content
launch_alias_exists = "alias protontricks-launch" in content
with open(bashrc_path, 'a') as f:
if not protontricks_alias_exists:
self.logger.info("Adding protontricks alias to ~/.bashrc")
alias_cmd = self._get_flatpak_alias_string()
f.write(f"\nalias protontricks='{alias_cmd}'\n")
if not launch_alias_exists:
self.logger.info("Adding protontricks-launch alias to ~/.bashrc")
launch_alias_cmd = self._get_flatpak_alias_string(command='protontricks-launch')
f.write(f"\nalias protontricks-launch='{launch_alias_cmd}'\n")
return True
self.logger.error("~/.bashrc not found, skipping alias creation")
return False
except Exception as e:
self.logger.error(f"Failed to create protontricks aliases: {e}")
return False
def list_non_steam_shortcuts(self) -> Dict[str, str]:
"""
List ALL non-Steam shortcuts.
Uses native VDF parsing when enabled, else protontricks -l.
Returns dict mapping shortcut name to AppID.
"""
if self.use_native_operations:
self.logger.info("Listing non-Steam shortcuts via native VDF parsing...")
try:
return self._get_native_steam_service().list_non_steam_shortcuts()
except Exception as e:
self.logger.warning(f"Native shortcut listing failed, falling back to protontricks: {e}")
self.logger.info("Listing ALL non-Steam shortcuts via protontricks...")
non_steam_shortcuts = {}
if not self.which_protontricks:
self.logger.info("Protontricks type/path not yet determined. Running detection...")
if not self.detect_protontricks():
self.logger.error("Protontricks detection failed. Cannot list shortcuts.")
return {}
self.logger.info(f"Protontricks detection successful: {self.which_protontricks}")
try:
cmd = []
if self.which_protontricks == 'flatpak':
cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks", "-l"]
elif self.protontricks_path:
cmd = [self.protontricks_path, "-l"]
else:
self.logger.error("Protontricks path not determined, cannot list shortcuts.")
return {}
self.logger.debug(f"Running command: {' '.join(cmd)}")
env = self._get_clean_subprocess_env()
result = subprocess.run(cmd, capture_output=True, text=True, check=True, encoding='utf-8', errors='ignore', env=env)
pattern = re.compile(r"Non-Steam shortcut:\s+(.+)\s+\((\d+)\)")
for line in result.stdout.splitlines():
line = line.strip()
match = pattern.match(line)
if match:
app_name = match.group(1).strip()
app_id = match.group(2).strip()
non_steam_shortcuts[app_name] = app_id
self.logger.debug(f"Found non-Steam shortcut: '{app_name}' with AppID {app_id}")
if not non_steam_shortcuts:
self.logger.warning("No non-Steam shortcuts found in protontricks output.")
except FileNotFoundError:
self.logger.error(f"Protontricks command not found. Path: {cmd[0] if cmd else 'N/A'}")
return {}
except subprocess.CalledProcessError as e:
self.logger.error(f"Error running protontricks -l (Exit code: {e.returncode}): {e}")
self.logger.error(f"Stderr (truncated): {e.stderr[:500] if e.stderr else ''}")
except Exception as e:
self.logger.error(f"Unexpected error listing non-Steam shortcuts: {e}", exc_info=True)
return {}
return non_steam_shortcuts
def protontricks_alias(self):
"""Create protontricks alias in ~/.bashrc (flatpak only). Returns True on success."""
self.logger.info("Creating protontricks alias in ~/.bashrc...")
try:
if self.which_protontricks == 'flatpak':
bashrc_path = os.path.expanduser("~/.bashrc")
protontricks_alias_exists = False
launch_alias_exists = False
if os.path.exists(bashrc_path):
with open(bashrc_path, 'r') as f:
content = f.read()
protontricks_alias_exists = "alias protontricks=" in content
launch_alias_exists = "alias protontricks-launch=" in content
with open(bashrc_path, 'a') as f:
if not protontricks_alias_exists:
f.write("\n# Jackify: Protontricks alias\n")
alias_cmd = self._get_flatpak_alias_string()
f.write(f"alias protontricks='{alias_cmd}'\n")
self.logger.debug("Added protontricks alias to ~/.bashrc")
if not launch_alias_exists:
f.write("\n# Jackify: Protontricks-launch alias\n")
launch_alias_cmd = self._get_flatpak_alias_string(command='protontricks-launch')
f.write(f"alias protontricks-launch='{launch_alias_cmd}'\n")
self.logger.debug("Added protontricks-launch alias to ~/.bashrc")
self.logger.info("Protontricks aliases created successfully")
return True
self.logger.info("Protontricks is not installed via flatpak, skipping alias creation")
return True
except Exception as e:
self.logger.error(f"Error creating protontricks alias: {e}")
return False
def _ensure_flatpak_cache_access(self, cache_path: Path) -> bool:
"""Ensure flatpak protontricks has filesystem access to the winetricks cache dir.
WINETRICKS_CACHE is passed at run time via flatpak run --env= (see run_protontricks)."""
if self.which_protontricks != 'flatpak':
return True
try:
cache_str = str(cache_path.resolve())
result = subprocess.run(
['flatpak', 'override', '--user', '--show', 'com.github.Matoking.protontricks'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0 and f'filesystems=' in result.stdout and cache_str in result.stdout:
self.logger.debug(f"Flatpak protontricks already has cache filesystem access: {cache_str}")
return True
self.logger.info(f"Granting flatpak protontricks filesystem access to winetricks cache: {cache_path}")
result = subprocess.run(
['flatpak', 'override', '--user', 'com.github.Matoking.protontricks',
f'--filesystem={cache_str}'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
self.logger.info("Successfully granted flatpak protontricks cache filesystem access")
return True
self.logger.warning(f"Failed to grant flatpak cache access: {result.stderr}")
return False
except Exception as e:
self.logger.warning(f"Could not configure flatpak cache access: {e}")
return False

View File

@@ -100,11 +100,7 @@ class ResolutionHandler:
while True:
user_res = input(f"{COLOR_PROMPT}Enter desired resolution (e.g., 1920x1080): {COLOR_RESET}").strip()
if self._validate_resolution_format(user_res):
# Optional: Add confirmation step here if desired
# confirm = input(f"{COLOR_PROMPT}Use resolution {user_res}? (Y/n): {COLOR_RESET}").lower()
# if confirm != 'n':
# return user_res
return user_res # Return validated resolution
return user_res
else:
print(f"{COLOR_ERROR}Invalid format. Please use format WxH (e.g., 1920x1080){COLOR_RESET}")
else:

View File

@@ -0,0 +1,156 @@
"""Shortcut creation methods for ShortcutHandler (Mixin)."""
import logging
import os
import time
from pathlib import Path
logger = logging.getLogger(__name__)
class ShortcutCreationMixin:
"""Mixin providing shortcut creation methods."""
def create_shortcut(self, executable_path=None, shortcut_name=None, launch_options="", icon_path="",
install_dir=None, download_dir=None):
"""
Create a new Steam shortcut entry.
Args:
executable_path (str): Path to the main executable (e.g., Hoolamike.exe)
shortcut_name (str): Name for the Steam shortcut
launch_options (str): Launch options string (optional)
icon_path (str): Path to the icon for the shortcut (optional)
install_dir: Optional modlist install path; its mountpoint is added to STEAM_COMPAT_MOUNTS
download_dir: Optional download path; its mountpoint is added to STEAM_COMPAT_MOUNTS
Returns:
tuple: (bool success, Optional[str] app_id) - Success status and the generated AppID, or None if failed.
"""
self.logger.info(f"Attempting to create shortcut for: {shortcut_name}")
self.logger.debug(f"[DEBUG] create_shortcut called with executable_path={executable_path}, shortcut_name={shortcut_name}, icon_path={icon_path}")
self._last_shortcuts_backup = None
self._safe_shortcuts_backup = None
self._shortcuts_file = None
if executable_path:
exe_dir = os.path.dirname(executable_path)
steam_icons_path = Path(exe_dir) / "Steam Icons"
steamicons_path = Path(exe_dir) / "SteamIcons"
if steam_icons_path.is_dir() and not steamicons_path.is_dir():
try:
steam_icons_path.rename(steamicons_path)
self.logger.info(f"Renamed 'Steam Icons' to 'SteamIcons' in {exe_dir}")
except Exception as e:
self.logger.warning(f"Failed to rename 'Steam Icons' to 'SteamIcons': {e}")
if not executable_path or not os.path.exists(executable_path):
self.logger.error(f"Invalid or non-existent executable path provided: {executable_path}")
return False, None
else:
start_dir = os.path.dirname(executable_path)
if not shortcut_name:
self.logger.error("Shortcut name not provided.")
return False, None
try:
shortcuts_file = self.shortcuts_path
self._shortcuts_file = shortcuts_file
if not shortcuts_file or not os.path.isfile(shortcuts_file):
self.logger.error("shortcuts.vdf path not found or is invalid.")
self.logger.error("Could not find the Steam shortcuts file (shortcuts.vdf).")
config_dir = os.path.dirname(shortcuts_file) if shortcuts_file else None
if config_dir and os.path.isdir(config_dir):
self.logger.warning(f"Attempting to create blank shortcuts.vdf at {shortcuts_file}")
with open(shortcuts_file, 'wb') as f:
f.write(b'\x00shortcuts\x00\x08\x08')
self.logger.info("Created blank shortcuts.vdf.")
else:
self.logger.error("Cannot create shortcuts.vdf as parent directory doesn't exist.")
return False, None
else:
config_dir = os.path.dirname(shortcuts_file)
if not os.path.isdir(config_dir):
self.logger.error(f"Config directory not found: {config_dir}")
self.logger.error(f"Steam config directory not found: {config_dir}")
return False, None
backup_dir = os.path.join(config_dir, "backups")
os.makedirs(backup_dir, exist_ok=True)
timestamp = time.strftime("%Y%m%d_%H%M%S")
backup_path = os.path.join(backup_dir, f"shortcuts_{timestamp}.bak")
if os.path.exists(shortcuts_file):
import shutil
shutil.copy2(shortcuts_file, backup_path)
self._last_shortcuts_backup = backup_path
self.logger.info(f"Created backup at {backup_path}")
else:
self.logger.warning(f"shortcuts.vdf does not exist at {shortcuts_file}, cannot create backup. Proceeding with potentially new file.")
compat_mounts_str = ""
try:
self.logger.info("Determining necessary STEAM_COMPAT_MOUNTS...")
mount_paths = self.path_handler.get_steam_compat_mount_paths(
install_dir=install_dir, download_dir=download_dir
)
if mount_paths:
compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{":".join(mount_paths)}"'
self.logger.info(f"Generated STEAM_COMPAT_MOUNTS string: {compat_mounts_str}")
else:
self.logger.info("No additional libraries or mountpoints needed for STEAM_COMPAT_MOUNTS.")
except Exception as e:
self.logger.error(f"Error determining STEAM_COMPAT_MOUNTS: {e}", exc_info=True)
final_launch_options = launch_options
if compat_mounts_str:
if final_launch_options:
final_launch_options = f"{compat_mounts_str} {final_launch_options}"
else:
final_launch_options = compat_mounts_str
if not final_launch_options.strip().endswith("%command%"):
if final_launch_options:
final_launch_options = f"{final_launch_options} %command%"
else:
final_launch_options = "%command%"
self.logger.debug(f"Final launch options string: {final_launch_options}")
success, app_id = self._add_steam_shortcut_safely(
shortcuts_file,
shortcut_name,
executable_path,
start_dir,
icon_path=icon_path,
launch_options=final_launch_options,
tags=["Jackify", "Tool"]
)
if not success:
self.logger.error("Failed to add shortcut entry safely.")
return False, None
self.logger.info(f"Shortcut created successfully for {shortcut_name} with AppID {app_id}")
return True, app_id
except Exception as e:
self.logger.error(f"Error creating shortcut: {e}", exc_info=True)
print(f"An error occurred while creating the shortcut: {e}")
return False, None
def _is_steam_deck(self):
try:
if os.path.exists('/etc/os-release'):
with open('/etc/os-release') as f:
if 'steamdeck' in f.read().lower():
return True
import subprocess
user_services = subprocess.run(['systemctl', '--user', 'list-units', '--type=service', '--no-pager'], capture_output=True, text=True)
if 'app-steam@autostart.service' in user_services.stdout:
return True
except Exception as e:
self.logger.warning(f"Error detecting Steam Deck: {e}")
return False

View File

@@ -0,0 +1,340 @@
"""Shortcut discovery and AppID methods for ShortcutHandler (Mixin)."""
import logging
import os
import re
from pathlib import Path
from typing import List, Dict, Optional
from .vdf_handler import VDFHandler
logger = logging.getLogger(__name__)
class ShortcutDiscoveryMixin:
"""Mixin providing shortcut discovery and AppID resolution methods."""
# DEAD CODE - Commented out 2026-01-29
# These methods were never completed. create_shortcut() requires arguments
# and returns tuple(bool, str), not dict. Kept for reference if CLI shortcut
# creation feature is implemented later.
#
# def create_shortcut_workflow(self):
# """Run the complete shortcut creation workflow"""
# shortcut_data = self.create_shortcut()
# if not shortcut_data:
# return False
# return True
#
# def create_new_modlist_shortcut(self):
# """Create a new modlist shortcut in Steam"""
# print("\nShortcut Creation")
# ...
# modlist_data = self.create_shortcut() # BUG: needs args, returns tuple not dict
# ...
def get_selected_modlist(self):
"""
Get the selected modlist string in the format expected by ModlistHandler.configure_modlist
Returns:
str: Selected modlist string in the format "Non-Steam shortcut: Name (AppID)"
or None if no modlist was selected
"""
return getattr(self, 'selected_modlist', None)
def get_appid_for_shortcut(self, shortcut_name: str, exe_path: Optional[str] = None) -> Optional[str]:
"""
Find the current AppID for a given shortcut name and (optionally) executable path.
Primary method: Read directly from shortcuts.vdf (reliable, no external dependencies)
Fallback method: Use protontricks (if available)
Args:
shortcut_name (str): The name of the Steam shortcut.
exe_path (Optional[str]): The path to the executable (for robust matching after Steam restart).
Returns:
Optional[str]: The found AppID string, or None if not found or error occurs.
"""
self.logger.info(f"Attempting to find current AppID for shortcut: '{shortcut_name}' (exe_path: '{exe_path}')")
try:
appid = self.get_appid_from_vdf(shortcut_name, exe_path)
if appid:
self.logger.info(f"Successfully found AppID {appid} from shortcuts.vdf")
return appid
self.logger.info("AppID not found in shortcuts.vdf, trying protontricks as fallback...")
from .protontricks_handler import ProtontricksHandler
pt_handler = ProtontricksHandler(self.steamdeck)
if not pt_handler.detect_protontricks():
self.logger.warning("Protontricks not detected - cannot use as fallback")
return None
result = pt_handler.run_protontricks("-l")
if not result or result.returncode != 0:
self.logger.warning(f"Protontricks fallback failed: {result.stderr if result else 'No result'}")
return None
found_shortcuts = []
for line in result.stdout.splitlines():
m = re.search(r"Non-Steam shortcut:\s*(.*?)\s*\((\d+)\)$", line)
if m:
pt_name = m.group(1).strip()
pt_appid = m.group(2)
found_shortcuts.append((pt_name, pt_appid))
vdf_shortcuts = []
shortcuts_vdf_path = self.shortcuts_path
if shortcuts_vdf_path and os.path.isfile(shortcuts_vdf_path):
try:
shortcuts_data = VDFHandler.load(shortcuts_vdf_path, binary=True)
if shortcuts_data and 'shortcuts' in shortcuts_data:
for idx, shortcut in shortcuts_data['shortcuts'].items():
app_name = shortcut.get('AppName', shortcut.get('appname', '')).strip()
exe = shortcut.get('Exe', shortcut.get('exe', '')).strip('"').strip()
vdf_shortcuts.append((app_name, exe, idx))
except Exception as e:
self.logger.error(f"Error parsing shortcuts.vdf for exe path matching: {e}")
if exe_path:
exe_path_norm = os.path.abspath(os.path.expanduser(exe_path)).lower()
shortcut_name_clean = shortcut_name.strip().lower()
for pt_name, pt_appid in found_shortcuts:
for vdf_name, vdf_exe, vdf_idx in vdf_shortcuts:
if vdf_name.strip().lower() == pt_name.strip().lower() == shortcut_name_clean:
vdf_exe_norm = os.path.abspath(os.path.expanduser(vdf_exe)).lower()
if vdf_exe_norm == exe_path_norm:
self.logger.info(f"Found matching AppID {pt_appid} for shortcut '{pt_name}' with exe '{vdf_exe}' (input: '{exe_path}')")
return pt_appid
self.logger.error(f"No shortcut found matching both name '{shortcut_name}' and exe_path '{exe_path}'.")
return None
shortcut_name_clean = shortcut_name.strip().lower()
for pt_name, pt_appid in found_shortcuts:
if pt_name.strip().lower() == shortcut_name_clean:
self.logger.info(f"Found matching AppID {pt_appid} for shortcut '{pt_name}' (input: '{shortcut_name}')")
return pt_appid
self.logger.error(f"Could not find an AppID for shortcut named '{shortcut_name}' via protontricks.")
return None
except Exception as e:
self.logger.error(f"Error getting AppID for shortcut '{shortcut_name}': {e}")
self.logger.exception("Traceback:")
return None
def get_appid_from_vdf(self, shortcut_name: str, exe_path: Optional[str] = None) -> Optional[str]:
"""
Get AppID directly from shortcuts.vdf by reading the file and matching shortcut name/exe.
This is more reliable than using protontricks since it doesn't depend on external tools.
Args:
shortcut_name (str): The name of the Steam shortcut.
exe_path (Optional[str]): The path to the executable for additional validation.
Returns:
Optional[str]: The AppID as a string, or None if not found.
"""
self.logger.info(f"Looking up AppID from shortcuts.vdf for shortcut: '{shortcut_name}' (exe: '{exe_path}')")
if not self.shortcuts_path or not os.path.isfile(self.shortcuts_path):
self.logger.warning(f"Shortcuts.vdf not found at {self.shortcuts_path}")
return None
try:
shortcuts_data = VDFHandler.load(self.shortcuts_path, binary=True)
if not shortcuts_data or 'shortcuts' not in shortcuts_data:
self.logger.warning("No shortcuts found in shortcuts.vdf")
return None
shortcut_name_clean = shortcut_name.strip().lower()
for idx, shortcut in shortcuts_data['shortcuts'].items():
name = shortcut.get('AppName', shortcut.get('appname', '')).strip()
if name.lower() == shortcut_name_clean:
appid = shortcut.get('appid')
if appid:
if exe_path:
vdf_exe = shortcut.get('Exe', shortcut.get('exe', '')).strip('"').strip()
exe_path_norm = os.path.abspath(os.path.expanduser(exe_path)).lower()
vdf_exe_norm = os.path.abspath(os.path.expanduser(vdf_exe)).lower()
if vdf_exe_norm == exe_path_norm:
self.logger.info(f"Found AppID {appid} for shortcut '{name}' with matching exe '{vdf_exe}'")
return str(int(appid) & 0xFFFFFFFF)
else:
self.logger.debug(f"Found shortcut '{name}' but exe doesn't match: '{vdf_exe}' vs '{exe_path}'")
continue
else:
self.logger.info(f"Found AppID {appid} for shortcut '{name}' (no exe validation)")
return str(int(appid) & 0xFFFFFFFF)
self.logger.warning(f"No matching shortcut found in shortcuts.vdf for '{shortcut_name}'")
return None
except Exception as e:
self.logger.error(f"Error reading shortcuts.vdf: {e}")
self.logger.exception("Traceback:")
return None
def _scan_shortcuts_for_executable(self, executable_name: str) -> List[Dict[str, str]]:
"""
Scans the user's shortcuts.vdf file for entries pointing to a specific executable.
Args:
executable_name (str): The base name of the executable (e.g., "ModOrganizer.exe")
Returns:
List[Dict[str, str]]: A list of dictionaries, each containing {'name': AppName, 'path': StartDir}
for shortcuts matching the executable name.
"""
self.logger.info(f"Scanning {self.shortcuts_path} for executable '{executable_name}'...")
matched_shortcuts = []
if not self.shortcuts_path or not os.path.isfile(self.shortcuts_path):
self.logger.info(f"No shortcuts.vdf file found at {self.shortcuts_path} - this is normal for new Steam installations")
return []
shortcuts_file = self.shortcuts_path
try:
shortcuts_data = VDFHandler.load(shortcuts_file, binary=True)
if shortcuts_data is None or 'shortcuts' not in shortcuts_data:
self.logger.warning(f"Could not load or parse data from {shortcuts_file}")
return []
for shortcut_id, shortcut in shortcuts_data['shortcuts'].items():
if not isinstance(shortcut, dict):
self.logger.warning(f"Skipping invalid shortcut entry (not a dict) at index {shortcut_id} in {shortcuts_file}")
continue
app_name = shortcut.get('AppName', shortcut.get('appname'))
exe_path = shortcut.get('Exe', shortcut.get('exe', '')).strip('"')
start_dir = shortcut.get('StartDir', shortcut.get('startdir', '')).strip('"')
if app_name and start_dir and os.path.basename(exe_path) == executable_name:
is_valid = True
if executable_name == "ModOrganizer.exe":
if not (Path(start_dir) / 'ModOrganizer.ini').exists():
self.logger.warning(f"Found MO2 shortcut '{app_name}' but ModOrganizer.ini missing in '{start_dir}'")
is_valid = False
if is_valid:
matched_shortcuts.append({'name': app_name, 'path': start_dir})
self.logger.debug(f"Found '{executable_name}' shortcut in VDF: {app_name} -> {start_dir}")
except Exception as e:
self.logger.error(f"Error processing {shortcuts_file}: {e}")
return []
self.logger.info(f"Scan complete. Found {len(matched_shortcuts)} potential '{executable_name}' shortcuts in VDF file.")
return matched_shortcuts
def discover_executable_shortcuts(self, executable_name: str) -> List[str]:
"""
Discovers non-Steam shortcuts for a specific executable, cross-referencing
VDF files with the Protontricks runtime list.
Args:
executable_name (str): The base name of the executable (e.g., "ModOrganizer.exe")
Returns:
List[str]: A list of strings in the format "Non-Steam shortcut: Name (AppID)"
for valid, matched shortcuts.
"""
self.logger.info(f"Discovering configured shortcuts for '{executable_name}'...")
vdf_shortcuts = self._scan_shortcuts_for_executable(executable_name)
if not vdf_shortcuts:
self.logger.warning(f"No '{executable_name}' shortcuts found in VDF files.")
pt_result = self.protontricks_handler.run_protontricks("-l")
if not pt_result or pt_result.returncode != 0:
self.logger.error(f"Protontricks failed to list applications: {pt_result.stderr if pt_result else 'No result'}")
return []
pt_shortcuts = {}
for line in pt_result.stdout.splitlines():
line = line.strip()
if "Non-Steam shortcut:" in line:
match = re.search(r"Non-Steam shortcut:\s*(.*?)\s*\((\d+)\)$", line)
if match:
pt_name = match.group(1).strip()
pt_appid = match.group(2)
pt_shortcuts[pt_name] = pt_appid
if not pt_shortcuts:
self.logger.warning("No Non-Steam shortcuts listed by Protontricks.")
return []
final_list = []
for vdf_shortcut in vdf_shortcuts:
vdf_name = vdf_shortcut['name']
if vdf_name in pt_shortcuts:
runtime_appid = pt_shortcuts[vdf_name]
modlist_string = f"Non-Steam shortcut: {vdf_name} ({runtime_appid})"
final_list.append(modlist_string)
self.logger.debug(f"Validated shortcut: {modlist_string}")
if not final_list:
self.logger.warning(f"No shortcuts for '{executable_name}' found in VDF matched the Protontricks list.")
self.logger.info(f"Discovery complete. Found {len(final_list)} validated shortcuts for '{executable_name}'.")
return final_list
def find_shortcuts_by_exe(self, executable_name: str) -> List[Dict]:
"""Finds shortcuts in shortcuts.vdf that point to a specific executable.
Args:
executable_name: The name of the executable (e.g., "ModOrganizer.exe")
to search for within the 'Exe' path.
Returns:
A list of dictionaries, each representing a matching shortcut
and containing keys like 'AppName', 'Exe', 'StartDir'.
Returns an empty list if no matches are found or an error occurs.
"""
self.logger.info(f"Scanning {self.shortcuts_path} for executable: {executable_name}")
matching_shortcuts = []
if not self.shortcuts_path or not os.path.isfile(self.shortcuts_path):
self.logger.info(f"No shortcuts.vdf file found at {self.shortcuts_path} - this is normal for new Steam installations")
return []
vdf_path = self.shortcuts_path
try:
self.logger.debug(f"Parsing shortcuts file: {vdf_path}")
shortcuts_data = VDFHandler.load(vdf_path, binary=True)
if not shortcuts_data or 'shortcuts' not in shortcuts_data:
self.logger.warning(f"Shortcuts data is empty or invalid in {vdf_path}")
return []
shortcuts_dict = shortcuts_data.get('shortcuts', {})
for index, shortcut_details in shortcuts_dict.items():
if not isinstance(shortcut_details, dict):
self.logger.warning(f"Skipping invalid shortcut entry at index {index} in {vdf_path}")
continue
exe_path = shortcut_details.get('Exe', shortcut_details.get('exe', '')).strip('"')
app_name = shortcut_details.get('AppName', shortcut_details.get('appname', 'Unknown Shortcut'))
if executable_name in os.path.basename(exe_path):
self.logger.info(f"Found matching shortcut '{app_name}' in {vdf_path}")
app_id = shortcut_details.get('appid', shortcut_details.get('AppID', shortcut_details.get('appId', None)))
start_dir = shortcut_details.get('StartDir', shortcut_details.get('startdir', '')).strip('"')
match = {
'AppName': app_name,
'Exe': exe_path,
'StartDir': start_dir,
'appid': app_id
}
matching_shortcuts.append(match)
else:
self.logger.debug(f"Skipping shortcut '{app_name}': Exe path '{exe_path}' does not contain '{executable_name}'")
except Exception as e:
self.logger.error(f"Error processing shortcuts file {vdf_path}: {e}", exc_info=True)
return []
if not matching_shortcuts:
self.logger.debug(f"No shortcuts found pointing to '{executable_name}' in {vdf_path}.")
return matching_shortcuts

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,162 @@
"""Launch options and icon methods for ShortcutHandler (Mixin)."""
import logging
import os
import shutil
import time
import vdf
logger = logging.getLogger(__name__)
class ShortcutLaunchOptionsMixin:
"""Mixin providing launch options and icon methods."""
def update_shortcut_launch_options(self, app_name, exe_path, new_launch_options):
"""
Updates the LaunchOptions for a specific existing shortcut in shortcuts.vdf by matching AppName and Exe.
Args:
app_name (str): The AppName of the shortcut to update (from config summary).
exe_path (str): The Exe path of the shortcut to update (from config summary, including quotes if present in VDF).
new_launch_options (str): The new string to set for LaunchOptions.
Returns:
bool: True if the update was successful, False otherwise.
"""
self.logger.info(f"Attempting to update launch options for shortcut with AppName '{app_name}' and Exe '{exe_path}' (no AppID matching)...")
shortcuts_file = self.path_handler._find_shortcuts_vdf()
if not shortcuts_file:
self.logger.error("Could not find shortcuts.vdf to update.")
return False
data = {'shortcuts': {}}
try:
if os.path.exists(shortcuts_file):
with open(shortcuts_file, 'rb') as f:
file_data = f.read()
if file_data:
data = vdf.binary_loads(file_data)
if 'shortcuts' not in data:
data['shortcuts'] = {}
else:
self.logger.error(f"shortcuts.vdf does not exist at {shortcuts_file}. Cannot update.")
return False
except Exception as e:
self.logger.error(f"Error reading or parsing shortcuts.vdf: {e}")
return False
def _normalize_path(p: str) -> str:
try:
p_clean = os.path.abspath(os.path.expanduser(p.strip().strip('"')))
return os.path.normpath(p_clean).lower()
except Exception:
return p.strip().strip('"').lower()
exe_norm = _normalize_path(exe_path)
target_index = None
for index, shortcut_data in data.get('shortcuts', {}).items():
shortcut_name = (shortcut_data.get('AppName', '') or '').strip()
shortcut_exe_raw = shortcut_data.get('Exe', '')
shortcut_exe_norm = _normalize_path(shortcut_exe_raw)
if shortcut_name == app_name and shortcut_exe_norm == exe_norm:
target_index = index
break
if target_index is None:
self.logger.error(f"Could not find shortcut with AppName '{app_name}' and Exe '{exe_path}' in shortcuts.vdf.")
for index, shortcut_data in data.get('shortcuts', {}).items():
shortcut_name = shortcut_data.get('AppName', '')
shortcut_exe = shortcut_data.get('Exe', '')
self.logger.error(f"Found shortcut: AppName='{shortcut_name}', Exe='{shortcut_exe}' -> norm='{_normalize_path(shortcut_exe)}'")
return False
if target_index in data['shortcuts']:
self.logger.info(f"Found shortcut at index {target_index}. Updating LaunchOptions...")
data['shortcuts'][target_index]['LaunchOptions'] = new_launch_options
else:
self.logger.error(f"Target index {target_index} not found in shortcuts dictionary after identification.")
return False
try:
temp_file = f"{shortcuts_file}.temp"
with open(temp_file, 'wb') as f:
vdf_data = vdf.binary_dumps(data)
f.write(vdf_data)
backup_dir = os.path.join(os.path.dirname(shortcuts_file), "backups")
os.makedirs(backup_dir, exist_ok=True)
timestamp = time.strftime("%Y%m%d_%H%M%S")
backup_path = os.path.join(backup_dir, f"shortcuts_update_{app_name}_{timestamp}.bak")
if os.path.exists(shortcuts_file):
shutil.copy2(shortcuts_file, backup_path)
self.logger.info(f"Created backup before update at {backup_path}")
shutil.move(temp_file, shortcuts_file)
self.logger.info(f"Successfully updated LaunchOptions for shortcut '{app_name}' in {shortcuts_file}.")
return True
except Exception as e:
self.logger.error(f"Error writing updated shortcuts.vdf: {e}")
if 'backup_path' in locals() and os.path.exists(backup_path):
try:
shutil.copy2(backup_path, shortcuts_file)
self.logger.warning(f"Restored shortcuts.vdf from backup {backup_path} after update failure.")
except Exception as restore_e:
self.logger.critical(f"CRITICAL: Failed to write updated shortcuts.vdf AND failed to restore backup! Error: {restore_e}")
return False
@staticmethod
def get_steam_shortcut_icon_path(exe_path, steamicons_dir=None, logger=None):
"""
Select the best icon for a Steam shortcut given an executable path and optional SteamIcons directory.
Prefers grid-tall.png, else any .png, else returns ''.
Logs selection steps if logger is provided.
"""
exe_dir = os.path.dirname(exe_path)
if not steamicons_dir:
steamicons_dir = os.path.join(exe_dir, "SteamIcons")
if logger:
logger.debug(f"[DEBUG] Looking for Steam shortcut icon in: {steamicons_dir}")
if os.path.isdir(steamicons_dir):
preferred_icon = os.path.join(steamicons_dir, "grid-tall.png")
if os.path.isfile(preferred_icon):
if logger:
logger.debug(f"[DEBUG] Using grid-tall.png as shortcut icon: {preferred_icon}")
return preferred_icon
pngs = [f for f in os.listdir(steamicons_dir) if f.lower().endswith('.png')]
if pngs:
icon_path = os.path.join(steamicons_dir, pngs[0])
if logger:
logger.debug(f"[DEBUG] Using fallback icon for shortcut: {icon_path}")
return icon_path
if logger:
logger.debug("[DEBUG] No .png icon found in SteamIcons directory.")
return ""
if logger:
logger.debug("[DEBUG] No SteamIcons directory found; shortcut will have no icon.")
return ""
def write_nxmhandler_ini(self, modlist_dir, mo2_exe_path):
"""
Create nxmhandler.ini in the modlist directory to suppress the NXM Handling popup on first MO2 launch.
If the file already exists, do nothing.
The executable path will be written as Z:\\<absolute path with double backslashes>, matching MO2's format.
"""
ini_path = os.path.join(modlist_dir, "nxmhandler.ini")
if os.path.exists(ini_path):
self.logger.info(f"nxmhandler.ini already exists at {ini_path}")
return
abs_path = os.path.abspath(mo2_exe_path)
z_path = f"Z:{abs_path}"
win_path = z_path.replace('/', '\\')
win_path = win_path.replace('\\', '\\\\')
content = (
"[handlers]\n"
"size=1\n"
"1\\games=\"skyrimse,skyrim\"\n"
f"1\\executable={win_path}\n"
"1\\arguments=\n"
)
with open(ini_path, "w") as f:
f.write(content)
self.logger.info(f"[SUCCESS] nxmhandler.ini written to {ini_path}")

View File

@@ -0,0 +1,293 @@
"""Steam restart methods for ShortcutHandler (Mixin)."""
import logging
import os
import subprocess
import time
from typing import Optional, Callable
logger = logging.getLogger(__name__)
def _resolve_steam_exe():
"""Resolve steam executable for legacy restart path (same logic as steam_restart_service)."""
try:
from jackify.backend.services.steam_restart_service import _get_steam_executable
return _get_steam_executable(os.environ)
except Exception:
import shutil
exe = shutil.which("steam")
if exe:
return exe
for p in ("/usr/games/steam", "/usr/bin/steam"):
if os.path.isfile(p) and os.access(p, os.X_OK):
return p
return "steam"
class ShortcutSteamRestartMixin:
"""Mixin providing Steam restart methods."""
def secure_steam_restart(self, status_callback: Optional[Callable[[str], None]] = None) -> bool:
"""
Secure Steam restart with comprehensive error handling to prevent segfaults.
Now delegates to the robust steam restart service for cross-distro compatibility.
"""
try:
from ..services.steam_restart_service import robust_steam_restart
return robust_steam_restart(progress_callback=status_callback, timeout=60)
except ImportError as e:
self.logger.error(f"Failed to import steam restart service: {e}")
return self._legacy_secure_steam_restart(status_callback)
except Exception as e:
self.logger.error(f"Error in robust steam restart: {e}")
return self._legacy_secure_steam_restart(status_callback)
def _legacy_secure_steam_restart(self, status_callback: Optional[Callable[[str], None]] = None) -> bool:
"""
Legacy secure Steam restart implementation (fallback).
"""
self.logger.info("Attempting secure Steam restart sequence...")
def safe_subprocess_run(cmd, **kwargs):
try:
return subprocess.run(cmd, **kwargs)
except Exception as e:
self.logger.error(f"Subprocess error with cmd {cmd}: {e}")
return subprocess.CompletedProcess(cmd, 1, "", str(e))
def safe_subprocess_popen(cmd, **kwargs):
try:
return subprocess.Popen(cmd, **kwargs)
except Exception as e:
self.logger.error(f"Popen error with cmd {cmd}: {e}")
return None
if self._is_steam_deck():
self.logger.info("Detected Steam Deck. Using systemd to restart Steam.")
if status_callback:
try:
status_callback("Restarting Steam via systemd...")
except Exception as e:
self.logger.warning(f"Status callback error: {e}")
try:
result = safe_subprocess_run(['systemctl', '--user', 'restart', 'app-steam@autostart.service'], capture_output=True, text=True, timeout=30)
self.logger.info(f"systemctl restart output: {result.stdout.strip()} {result.stderr.strip()}")
time.sleep(10)
check = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10)
if check.returncode == 0:
self.logger.info("Steam restarted successfully via systemd.")
if status_callback:
try:
status_callback("Steam Started")
except Exception as e:
self.logger.warning(f"Status callback error: {e}")
return True
else:
self.logger.error("Steam did not start after systemd restart.")
if status_callback:
try:
status_callback("Start Failed")
except Exception as e:
self.logger.warning(f"Status callback error: {e}")
return False
except Exception as e:
self.logger.error(f"Error restarting Steam via systemd: {e}")
if status_callback:
try:
status_callback("Restart Failed")
except Exception as e:
self.logger.warning(f"Status callback error: {e}")
return False
try:
if status_callback:
try:
status_callback("Stopping Steam...")
except Exception as e:
self.logger.warning(f"Status callback error: {e}")
self.logger.info("Attempting clean Steam shutdown via 'steam -shutdown'...")
shutdown_timeout = 30
result = safe_subprocess_run(['steam', '-shutdown'], timeout=shutdown_timeout, check=False, capture_output=True, text=True)
if result.returncode != 1:
self.logger.debug("'steam -shutdown' command executed (exit code ignored, verification follows).")
else:
self.logger.warning(f"'steam -shutdown' had issues: {result.stderr}")
except Exception as e:
self.logger.warning(f"Error executing 'steam -shutdown': {e}. Will proceed to check processes.")
if status_callback:
try:
status_callback("Waiting for Steam to close...")
except Exception as e:
self.logger.warning(f"Status callback error: {e}")
self.logger.info("Verifying Steam processes are terminated...")
max_attempts = 6
steam_closed_successfully = False
for attempt in range(max_attempts):
try:
check_cmd = ['pgrep', '-f', 'steamwebhelper']
self.logger.debug(f"Executing check: {' '.join(check_cmd)}")
result = safe_subprocess_run(check_cmd, capture_output=True, timeout=10)
if result.returncode != 0:
self.logger.info("No Steam web helper processes found via pgrep.")
steam_closed_successfully = True
break
else:
try:
steam_pids = result.stdout.decode().strip().split('\n') if result.stdout else []
self.logger.debug(f"Steam web helper processes still detected (PIDs: {steam_pids}). Waiting... (Attempt {attempt + 1}/{max_attempts} after shutdown cmd)")
except Exception as e:
self.logger.warning(f"Error parsing pgrep output: {e}")
time.sleep(5)
except Exception as e:
self.logger.warning(f"Error checking Steam processes (attempt {attempt + 1}): {e}")
time.sleep(5)
if not steam_closed_successfully:
self.logger.debug("Steam processes still running after 'steam -shutdown'. Attempting fallback with 'pkill steam'...")
if status_callback:
try:
status_callback("Force stopping Steam...")
except Exception as e:
self.logger.warning(f"Status callback error: {e}")
try:
self.logger.info("Attempting force shutdown via 'pkill steam'...")
pkill_result = safe_subprocess_run(['pkill', '-f', 'steam'], timeout=15, check=False, capture_output=True, text=True)
self.logger.info(f"pkill steam result: {pkill_result.returncode} - {pkill_result.stdout.strip()} {pkill_result.stderr.strip()}")
time.sleep(3)
final_check = safe_subprocess_run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10)
if final_check.returncode != 0:
self.logger.info("Steam processes successfully terminated via pkill fallback.")
steam_closed_successfully = True
else:
self.logger.debug("Steam processes still running after pkill fallback.")
if status_callback:
try:
status_callback("Shutdown Failed")
except Exception as e:
self.logger.warning(f"Status callback error: {e}")
return False
except Exception as e:
self.logger.error(f"Error during pkill fallback: {e}")
if status_callback:
try:
status_callback("Shutdown Failed")
except Exception as e:
self.logger.warning(f"Status callback error: {e}")
return False
if not steam_closed_successfully:
self.logger.error("Failed to terminate Steam processes via all methods.")
if status_callback:
try:
status_callback("Shutdown Failed")
except Exception as e:
self.logger.warning(f"Status callback error: {e}")
return False
self.logger.info("Steam confirmed closed.")
steam_exe = _resolve_steam_exe()
start_methods = [
{"name": "Popen", "cmd": [steam_exe, "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True}},
{"name": "setsid", "cmd": ["setsid", steam_exe, "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL}},
{"name": "nohup", "cmd": ["nohup", steam_exe, "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True}}
]
steam_start_initiated = False
for i, method in enumerate(start_methods):
method_name = method["name"]
status_msg = f"Starting Steam ({method_name})"
if status_callback:
try:
status_callback(status_msg)
except Exception as e:
self.logger.warning(f"Status callback error: {e}")
self.logger.info(f"Attempting to start Steam using method: {method_name}")
try:
process = safe_subprocess_popen(method["cmd"], **method["kwargs"])
if process is not None:
self.logger.info(f"Initiated Steam start with {method_name}.")
time.sleep(5)
check_result = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10)
if check_result.returncode == 0:
self.logger.info(f"Steam process detected after using {method_name}. Proceeding to wait phase.")
steam_start_initiated = True
break
else:
self.logger.warning(f"Steam process not detected after initiating with {method_name}. Trying next method.")
else:
self.logger.warning(f"Failed to start process with {method_name}. Trying next method.")
except FileNotFoundError:
self.logger.error(f"Command not found for method {method_name} (e.g., setsid, nohup). Trying next method.")
except Exception as e:
self.logger.error(f"Error starting Steam with {method_name}: {e}. Trying next method.")
if not steam_start_initiated:
self.logger.error("All methods to initiate Steam start failed.")
if status_callback:
try:
status_callback("Start Failed")
except Exception as e:
self.logger.warning(f"Status callback error: {e}")
return False
status_msg = "Waiting for Steam to fully start"
if status_callback:
try:
status_callback(status_msg)
except Exception as e:
self.logger.warning(f"Status callback error: {e}")
self.logger.info("Waiting up to 2 minutes for Steam to fully initialize...")
max_startup_wait = 120
elapsed_wait = 0
initial_wait_done = False
while elapsed_wait < max_startup_wait:
try:
result = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10)
if result.returncode == 0:
if not initial_wait_done:
self.logger.info("Steam process detected. Waiting additional time for full initialization...")
initial_wait_done = True
time.sleep(5)
elapsed_wait += 5
if initial_wait_done and elapsed_wait >= 15:
final_check = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10)
if final_check.returncode == 0:
if status_callback:
try:
status_callback("Steam Started")
except Exception as e:
self.logger.warning(f"Status callback error: {e}")
self.logger.info("Steam confirmed running after wait.")
return True
else:
self.logger.warning("Steam process disappeared during final initialization wait.")
break
else:
self.logger.debug(f"Steam process not yet detected. Waiting... ({elapsed_wait + 5}s)")
time.sleep(5)
elapsed_wait += 5
except Exception as e:
self.logger.warning(f"Error during Steam startup wait: {e}")
time.sleep(5)
elapsed_wait += 5
self.logger.error("Steam failed to start/initialize within the allowed time.")
if status_callback:
try:
status_callback("Start Timed Out")
except Exception as e:
self.logger.warning(f"Status callback error: {e}")
return False

View File

@@ -0,0 +1,318 @@
"""VDF backup/restore and modification methods for ShortcutHandler (Mixin)."""
import logging
import os
import shutil
import subprocess
import time
from pathlib import Path
import glob
import vdf
from .vdf_handler import VDFHandler
logger = logging.getLogger(__name__)
class ShortcutVDFManagementMixin:
"""Mixin providing VDF file management methods."""
def _check_and_restore_shortcuts_vdf(self):
"""
Check if shortcuts.vdf exists and restore from backup if missing.
Returns:
bool: True if file exists or was restored, False if unable to restore
"""
shortcuts_files = []
for user_dir in os.listdir(self.shortcuts_path):
shortcuts_file = os.path.join(self.shortcuts_path, user_dir, "config", "shortcuts.vdf")
if os.path.dirname(shortcuts_file):
shortcuts_files.append(shortcuts_file)
missing_files = []
for file_path in shortcuts_files:
if not os.path.exists(file_path):
self.logger.warning(f"shortcuts.vdf is missing at: {file_path}")
missing_files.append(file_path)
if not missing_files:
self.logger.debug("All shortcuts.vdf files are present")
return True
restored = 0
for file_path in missing_files:
backup_files = sorted(glob.glob(f"{file_path}.*.bak"), reverse=True)
if backup_files:
try:
shutil.copy2(backup_files[0], file_path)
self.logger.info(f"Restored {file_path} from {backup_files[0]}")
restored += 1
continue
except Exception as e:
self.logger.error(f"Failed to restore from timestamped backup: {e}")
simple_backup = f"{file_path}.bak"
if os.path.exists(simple_backup):
try:
shutil.copy2(simple_backup, file_path)
self.logger.info(f"Restored {file_path} from simple backup")
restored += 1
continue
except Exception as e:
self.logger.error(f"Failed to restore from simple backup: {e}")
if restored == len(missing_files):
self.logger.info("Successfully restored all missing shortcuts.vdf files")
return True
elif restored > 0:
self.logger.warning(f"Partially restored {restored}/{len(missing_files)} shortcuts.vdf files")
return True
else:
self.logger.error("Failed to restore any shortcuts.vdf files")
return False
def _modify_shortcuts_directly(self, shortcuts_file, modlist_name, mo2_path, mo2_dir):
"""
Directly modify shortcuts.vdf in a way that preserves Steam's exact binary format.
This is a fallback method when regular VDF handling might cause issues.
Args:
shortcuts_file (str): Path to shortcuts.vdf
modlist_name (str): Name for the modlist
mo2_path (str): Path to ModOrganizer.exe
mo2_dir (str): Directory containing ModOrganizer.exe
Returns:
bool: True if successful, False otherwise
"""
try:
backup_path = f"{shortcuts_file}.{int(time.time())}.bak"
shutil.copy2(shortcuts_file, backup_path)
self.logger.info(f"Created backup before direct modification: {backup_path}")
if not os.path.exists(shortcuts_file) or os.path.getsize(shortcuts_file) == 0:
with open(shortcuts_file, 'wb') as f:
f.write(b'\x00shortcuts\x00\x08\x08')
self.logger.info(f"Created new shortcuts.vdf file at {shortcuts_file}")
try:
import sys
import importlib.util
steam_vdf_spec = importlib.util.find_spec("steam_vdf")
if steam_vdf_spec is None:
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)
import vdf as steam_vdf
with open(shortcuts_file, 'rb') as f:
shortcuts_data = steam_vdf.load(f)
max_id = -1
if 'shortcuts' in shortcuts_data:
for id_str in shortcuts_data['shortcuts']:
try:
id_num = int(id_str)
if id_num > max_id:
max_id = id_num
except ValueError:
pass
new_id = max_id + 1
if 'shortcuts' not in shortcuts_data:
shortcuts_data['shortcuts'] = {}
shortcuts_data['shortcuts'][str(new_id)] = {
'AppName': modlist_name,
'Exe': f'"{mo2_path}"',
'StartDir': mo2_dir,
'icon': '',
'ShortcutPath': '',
'LaunchOptions': '',
'IsHidden': 0,
'AllowDesktopConfig': 1,
'AllowOverlay': 1,
'OpenVR': 0,
'Devkit': 0,
'DevkitGameID': '',
'LastPlayTime': 0
}
with open(shortcuts_file, 'wb') as f:
steam_vdf.dump(shortcuts_data, f)
self.logger.info(f"Added shortcut for {modlist_name} using steam-vdf library")
return True
except Exception as e:
self.logger.warning(f"Failed to use steam-vdf library: {e}")
self.logger.info("Falling back to VDFHandler for shortcuts.vdf modification")
shortcuts_data = VDFHandler.load(shortcuts_file, binary=True)
if not shortcuts_data:
shortcuts_data = {'shortcuts': {}}
new_id = len(shortcuts_data.get('shortcuts', {}))
new_entry = {
'AppName': modlist_name,
'Exe': f'"{mo2_path}"',
'StartDir': mo2_dir,
'icon': '',
'ShortcutPath': '',
'LaunchOptions': '',
'IsHidden': 0,
'AllowDesktopConfig': 1,
'AllowOverlay': 1,
'OpenVR': 0,
'Devkit': 0,
'DevkitGameID': '',
'LastPlayTime': 0
}
if 'shortcuts' not in shortcuts_data:
shortcuts_data['shortcuts'] = {}
shortcuts_data['shortcuts'][str(new_id)] = new_entry
result = VDFHandler.save(shortcuts_file, shortcuts_data, binary=True)
self.logger.info(f"Added shortcut for {modlist_name} using VDFHandler")
return result
except Exception as e:
self.logger.error(f"Error in direct shortcut modification: {e}")
return False
def _add_steam_shortcut_safely(self, shortcuts_file, app_name, exe_path, start_dir, icon_path="", launch_options="", tags=None):
"""
Adds a new shortcut entry to the shortcuts.vdf file using the correct binary format.
This method is carefully designed to maintain file integrity.
Args:
shortcuts_file (str): Path to shortcuts.vdf
app_name (str): Name for the shortcut
exe_path (str): Path to the executable
start_dir (str): Start directory for the executable
icon_path (str): Path to icon file (optional)
launch_options (str): Command line options (optional)
tags (list): List of tags (optional)
Returns:
tuple: (bool success, str app_id) - Success status and calculated AppID
"""
if tags is None:
tags = []
data = {'shortcuts': {}}
try:
if os.path.exists(shortcuts_file):
with open(shortcuts_file, 'rb') as f:
file_data = f.read()
if file_data:
try:
data = vdf.binary_loads(file_data)
if 'shortcuts' not in data:
data['shortcuts'] = {}
except Exception as e:
self.logger.warning(f"Could not parse existing shortcuts.vdf: {e}")
data = {'shortcuts': {}}
else:
self.logger.info(f"shortcuts.vdf not found at {shortcuts_file}. A new file will be created.")
except Exception as e:
self.logger.warning(f"Error accessing shortcuts.vdf: {e}")
data = {'shortcuts': {}}
if 'shortcuts' not in data:
data['shortcuts'] = {}
next_index = 0
if data.get('shortcuts'):
shortcut_indices = [int(k) for k in data['shortcuts'].keys() if k.isdigit()]
if shortcut_indices:
next_index = max(shortcut_indices) + 1
new_shortcut = {
'AppName': app_name,
'Exe': f'"{exe_path}"',
'StartDir': f'"{start_dir}"',
'icon': icon_path,
'ShortcutPath': "",
'LaunchOptions': launch_options,
'IsHidden': 0,
'AllowDesktopConfig': 1,
'AllowOverlay': 1,
'OpenVR': 0,
'Devkit': 0,
'DevkitGameID': '',
'DevkitOverrideAppID': 0,
'LastPlayTime': 0,
'FlatpakAppID': '',
'IsInstalled': 1,
}
if tags:
new_shortcut['tags'] = {str(i): tag for i, tag in enumerate(tags)}
app_id = (0x80000000 + int(next_index)) % (2**32)
if app_id > 0x7FFFFFFF:
app_id = app_id - 0x100000000
new_shortcut['appid'] = app_id
data['shortcuts'][str(next_index)] = new_shortcut
self.logger.info(f"Adding shortcut '{app_name}' at index {next_index}")
try:
temp_file = f"{shortcuts_file}.temp"
with open(temp_file, 'wb') as f:
vdf_data = vdf.binary_dumps(data)
f.write(vdf_data)
shutil.move(temp_file, shortcuts_file)
self.logger.info(f"Successfully updated shortcuts.vdf! AppID: {app_id}")
return True, app_id
except Exception as e:
self.logger.error(f"Error: Failed to write updated shortcuts.vdf: {e}")
return False, None
def _verify_and_restore_shortcuts(self):
"""
Verify shortcuts.vdf exists after Steam restart and restore it if needed.
"""
shortcuts_file = getattr(self, '_shortcuts_file', None)
if not shortcuts_file:
self.logger.warning("No shortcuts file to verify")
return
if not os.path.exists(shortcuts_file) or os.path.getsize(shortcuts_file) == 0:
self.logger.warning(f"shortcuts.vdf missing or empty after restart: {shortcuts_file}")
safe_backup = getattr(self, '_safe_shortcuts_backup', None)
if safe_backup and os.path.exists(safe_backup):
try:
shutil.copy2(safe_backup, shortcuts_file)
self.logger.info(f"Restored shortcuts.vdf from pre-restart backup")
print("Restored shortcuts file after Steam restart")
return
except Exception as e:
self.logger.error(f"Failed to restore from pre-restart backup: {e}")
backup = getattr(self, '_last_shortcuts_backup', None)
if backup and os.path.exists(backup):
try:
shutil.copy2(backup, shortcuts_file)
self.logger.info(f"Restored shortcuts.vdf from regular backup")
print("Restored shortcuts file after Steam restart")
except Exception as e:
self.logger.error(f"Failed to restore from backup: {e}")
print("Failed to restore shortcuts file. You may need to recreate your shortcut.")
else:
self.logger.info(f"shortcuts.vdf verified intact after restart")

View File

@@ -65,7 +65,7 @@ def get_clean_subprocess_env(extra_env=None):
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.
# Critical for tools 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:
@@ -73,10 +73,10 @@ def get_clean_subprocess_env(extra_env=None):
path_parts.append(sys_path)
# Add bundled tools directory to PATH if running as AppImage
# This ensures cabextract and winetricks are available to subprocesses
# cabextract and winetricks must be available to subprocesses
# System utilities (wget, curl, unzip, xz, gzip, sha256sum) come from system PATH
# Note: appdir was saved before env cleanup above
# Note: lz4 was only needed for TTW installer and is no longer bundled
# appdir saved before env cleanup above
# lz4 was only needed for TTW installer, no longer bundled
tools_dir = None
if appdir:

View File

@@ -0,0 +1,318 @@
"""
TTW installer backend: install_ttw_backend, start_ttw_installation, cleanup, stream output, integrate.
"""
import subprocess
from pathlib import Path
from typing import Optional, Tuple
from .logging_handler import LoggingHandler
from .subprocess_utils import get_clean_subprocess_env
class TTWInstallerBackendMixin:
"""Mixin providing TTW installation process and integration for TTWInstallerHandler."""
def install_ttw_backend(self, ttw_mpi_path: Path, ttw_output_path: Path) -> Tuple[bool, str]:
"""Install TTW using TTW_Linux_Installer."""
self.logger.info("Starting Tale of Two Wastelands installation via TTW_Linux_Installer")
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)
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}"
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 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"
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"
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("Executing TTW_Linux_Installer: %s", ' '.join(cmd))
try:
env = get_clean_subprocess_env()
exe_dir = str(self.ttw_installer_executable_path.parent)
process = subprocess.Popen(
cmd, cwd=exe_dir, env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, universal_newlines=True
)
if process.stdout:
for line in process.stdout:
line = line.rstrip()
if line:
self.logger.info("TTW_Linux_Installer: %s", line)
process.wait()
ret = process.returncode
if ret == 0:
self.logger.info("TTW installation completed successfully.")
return True, "TTW installation completed successfully!"
self.logger.error("TTW installation process returned non-zero exit code: %s", ret)
return False, f"TTW installation failed with exit code {ret}"
except Exception as e:
self.logger.error("Error executing TTW_Linux_Installer: %s", 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). Returns (process, error_message)."""
self.logger.info("Starting TTW installation (non-blocking mode)")
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)
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}"
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"
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"
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("Executing TTW_Linux_Installer: %s", ' '.join(cmd))
try:
env = get_clean_subprocess_env()
output_fh = open(output_file, 'w', encoding='utf-8', buffering=1)
exe_dir = str(self.ttw_installer_executable_path.parent)
process = subprocess.Popen(
cmd, cwd=exe_dir, env=env,
stdout=output_fh, stderr=subprocess.STDOUT, bufsize=1
)
self.logger.info("TTW_Linux_Installer process started (PID: %s), output to %s", process.pid, output_file)
process._output_fh = output_fh
return process, None
except Exception as e:
self.logger.error("Error starting TTW_Linux_Installer: %s", 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."""
if process:
if hasattr(process, '_output_fh'):
try:
process._output_fh.close()
except Exception:
pass
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 (DEPRECATED - use start_ttw_installation instead)."""
self.logger.info("Starting Tale of Two Wastelands installation via TTW_Linux_Installer (with output stream)")
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)
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}"
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"
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"
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("Executing TTW_Linux_Installer: %s", ' '.join(cmd))
try:
env = get_clean_subprocess_env()
exe_dir = str(self.ttw_installer_executable_path.parent)
process = subprocess.Popen(
cmd, cwd=exe_dir, env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, universal_newlines=True
)
if process.stdout:
for line in process.stdout:
line = line.rstrip()
if line:
self.logger.info("TTW_Linux_Installer: %s", 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!"
self.logger.error("TTW installation process returned non-zero exit code: %s", ret)
return False, f"TTW installation failed with exit code {ret}"
except Exception as e:
self.logger.error("Error executing TTW_Linux_Installer: %s", 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."""
import shutil
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:
if not ttw_output_path.exists():
logger.error("TTW output path does not exist: %s", 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("Invalid modlist directory structure: %s", modlist_install_dir)
return False
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
logger.info("Copying TTW output to %s", target_mod_dir)
if target_mod_dir.exists():
logger.info("Removing existing TTW mod at %s", target_mod_dir)
shutil.rmtree(target_mod_dir)
shutil.copytree(ttw_output_path, target_mod_dir)
logger.info("TTW output copied successfully")
ttw_esms = [
"Fallout3.esm", "Anchorage.esm", "ThePitt.esm", "BrokenSteel.esm",
"PointLookout.esm", "Zeta.esm", "TaleOfTwoWastelands.esm", "YUPTTW.esm"
]
for profile_dir in profiles_dir.iterdir():
if not profile_dir.is_dir():
continue
profile_name = profile_dir.name
logger.info("Processing profile: %s", profile_name)
modlist_file = profile_dir / "modlist.txt"
if modlist_file.exists():
with open(modlist_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
separator_found = False
ttw_mod_line = f"+{mod_folder_name}\n"
new_lines = []
for line in lines:
stripped = line.strip()
if stripped.startswith('+') and '[nodelete]' in stripped.lower():
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("Removing existing TTW mod entry: %s", stripped)
continue
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("Inserted TTW mod before separator: %s", line.strip())
new_lines.append(line)
if not separator_found:
new_lines.append(ttw_mod_line)
logger.warning("No TTW separator found in %s, appended to end", profile_name)
with open(modlist_file, 'w', encoding='utf-8') as f:
f.writelines(new_lines)
logger.info("Updated modlist.txt for %s", profile_name)
else:
logger.warning("modlist.txt not found for profile %s", profile_name)
plugins_file = profile_dir / "plugins.txt"
if plugins_file.exists():
with open(plugins_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
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]
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:
for esm in reversed(ttw_esms):
lines.insert(insert_index, f"{esm}\n")
else:
logger.warning("CaravanPack.esm not found in %s, appending TTW ESMs to end", profile_name)
for esm in ttw_esms:
lines.append(f"{esm}\n")
with open(plugins_file, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info("Updated plugins.txt for %s", profile_name)
else:
logger.warning("plugins.txt not found for profile %s", profile_name)
logger.info("TTW integration completed successfully")
return True
except Exception as e:
logger.error("Error integrating TTW into modlist: %s", e, exc_info=True)
return False

View File

@@ -18,7 +18,7 @@ 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
from .ttw_installer_backend import TTWInstallerBackendMixin
logger = logging.getLogger(__name__)
@@ -36,7 +36,7 @@ TTW_INSTALLER_RELEASE_URL = f"https://api.github.com/repos/{TTW_INSTALLER_REPO}/
TTW_INSTALLER_PINNED_VERSION = "0.0.7"
class TTWInstallerHandler:
class TTWInstallerHandler(TTWInstallerBackendMixin):
"""Handles TTW installation using TTW_Linux_Installer (replaces hoolamike for TTW)."""
def __init__(self, steamdeck: bool, verbose: bool, filesystem_handler: FileSystemHandler,
@@ -108,18 +108,37 @@ class TTWInstallerHandler:
target_dir = Path(install_dir) if install_dir else self.ttw_installer_dir
target_dir.mkdir(parents=True, exist_ok=True)
# Fetch release info (pinned version or latest)
# Fetch release info - always use pinned version when set; never use latest
if TTW_INSTALLER_PINNED_VERSION:
release_url = f"https://api.github.com/repos/{TTW_INSTALLER_REPO}/releases/tags/{TTW_INSTALLER_PINNED_VERSION}"
self.logger.info(f"Fetching pinned TTW_Linux_Installer version {TTW_INSTALLER_PINNED_VERSION} from {release_url}")
tag_candidates = [
TTW_INSTALLER_PINNED_VERSION,
f"v{TTW_INSTALLER_PINNED_VERSION}" if not TTW_INSTALLER_PINNED_VERSION.startswith("v") else None,
]
tag_candidates = [t for t in tag_candidates if t]
data = None
release_tag = None
for tag in tag_candidates:
release_url = f"https://api.github.com/repos/{TTW_INSTALLER_REPO}/releases/tags/{tag}"
self.logger.info(f"Fetching pinned TTW_Linux_Installer version {tag} from {release_url}")
resp = requests.get(release_url, timeout=15, verify=True)
if resp.status_code == 200:
data = resp.json()
release_tag = data.get("tag_name") or data.get("name")
break
if resp.status_code != 404:
resp.raise_for_status()
if not data:
return False, (
f"Pinned release {TTW_INSTALLER_PINNED_VERSION} not found on GitHub "
f"(tried tags: {', '.join(tag_candidates)}). Check repo and tag names."
)
else:
release_url = TTW_INSTALLER_RELEASE_URL
self.logger.info(f"Fetching latest TTW_Linux_Installer release from {release_url}")
resp = requests.get(release_url, timeout=15, verify=True)
resp.raise_for_status()
data = resp.json()
release_tag = data.get("tag_name") or data.get("name")
resp = requests.get(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
@@ -135,15 +154,15 @@ class TTWInstallerHandler:
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)}"
release_desc = f"release {release_tag}" if release_tag else "release"
return False, f"No suitable Linux TTW_Linux_Installer asset found in {release_desc}. 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"
return False, f"Release {release_tag or 'unknown'} is missing required asset metadata"
# Download to target directory
temp_path = target_dir / asset_name
@@ -282,515 +301,3 @@ class TTWInstallerHandler:
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()
# CRITICAL: cwd must be the directory containing the executable, not the extraction root
# This is because AppContext.BaseDirectory (used by TTW installer to find BundledBinaries)
# is the directory containing the executable, not the working directory
exe_dir = str(self.ttw_installer_executable_path.parent)
process = subprocess.Popen(
cmd,
cwd=exe_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()
# Note: TTW_Linux_Installer bundles its own lz4 and will find it via AppContext.BaseDirectory
# We set cwd to the executable's directory so AppContext.BaseDirectory matches the working directory
# Open output file for writing
output_fh = open(output_file, 'w', encoding='utf-8', buffering=1)
# Start process with output redirected to file
# CRITICAL: cwd must be the directory containing the executable, not the extraction root
# This is because AppContext.BaseDirectory (used by TTW installer to find BundledBinaries)
# is the directory containing the executable, not the working directory
exe_dir = str(self.ttw_installer_executable_path.parent)
process = subprocess.Popen(
cmd,
cwd=exe_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()
# CRITICAL: cwd must be the directory containing the executable, not the extraction root
# This is because AppContext.BaseDirectory (used by TTW installer to find BundledBinaries)
# is the directory containing the executable, not the working directory
exe_dir = str(self.ttw_installer_executable_path.parent)
process = subprocess.Popen(
cmd,
cwd=exe_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

@@ -165,7 +165,6 @@ class UIHandler:
def show_help(self, topic: str) -> None:
"""Display help information for a topic."""
try:
# This would typically load help content from a file or database
print(f"\nHelp: {topic}")
print("=" * (len(topic) + 6))
print("Help content would be displayed here.")

View File

@@ -63,11 +63,8 @@ class VDFHandler:
if file_name == "shortcuts.vdf":
return False
# Check exact filename match
if file_name in PROTECTED_VDF_FILES:
return True
# Check pattern match (for appmanifest_*.acf)
for pattern in PROTECTED_VDF_FILES:
if '*' in pattern and pattern.replace('*', '') in file_name:
return True
@@ -125,7 +122,7 @@ class VDFHandler:
return vdf.load(f_text)
except FileNotFoundError:
# This might be redundant due to os.path.exists checks, but keep for safety
# Possibly redundant with os.path.exists checks -- kept for safety
logger.error(f"VDF file not found during load operation: {file_path}")
return None
except PermissionError:

View File

@@ -0,0 +1,296 @@
"""Directory and download methods for InstallWabbajackHandler (Mixin)."""
import logging
import os
import shutil
from pathlib import Path
from typing import Optional
import requests
from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_PROMPT, COLOR_RESET, COLOR_WARNING
logger = logging.getLogger(__name__)
DEFAULT_WABBAJACK_PATH = "~/Wabbajack"
DEFAULT_WABBAJACK_NAME = "Wabbajack"
READLINE_AVAILABLE = False
try:
import readline
READLINE_AVAILABLE = True
except ImportError:
pass
except Exception as e:
logging.warning(f"Readline import failed: {e}")
try:
from .menu_handler import simple_path_completer
except ImportError:
simple_path_completer = None
class WabbajackDirectoryMixin:
"""Mixin providing directory setup and download methods."""
def _download_file(self, url: str, destination_path: Path) -> bool:
"""Downloads a file from a URL to a destination path.
Handles temporary file and overwrites destination if download succeeds.
Args:
url (str): The URL to download from.
destination_path (Path): The path to save the downloaded file.
Returns:
bool: True if download succeeds, False otherwise.
"""
self.logger.info(f"Downloading {destination_path.name} from {url}")
destination_path.parent.mkdir(parents=True, exist_ok=True)
temp_path = destination_path.with_suffix(destination_path.suffix + ".part")
self.logger.debug(f"Downloading to temporary path: {temp_path}")
try:
with requests.get(url, stream=True, timeout=30, verify=True) as r:
r.raise_for_status()
block_size = 8192
with open(temp_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=block_size):
if chunk:
f.write(chunk)
actual_downloaded_size = temp_path.stat().st_size
self.logger.debug(f"Download finished. Actual size: {actual_downloaded_size} bytes.")
shutil.move(str(temp_path), str(destination_path))
self.logger.info(f"Successfully downloaded and moved to {destination_path}")
return True
except requests.exceptions.RequestException as e:
self.logger.error(f"Download failed for {url}: {e}", exc_info=True)
print(f"\n{COLOR_ERROR}Error downloading {destination_path.name}: {e}{COLOR_RESET}")
if temp_path.exists():
try:
temp_path.unlink()
except OSError as unlink_err:
self.logger.error(f"Failed to remove partial download {temp_path}: {unlink_err}")
return False
except Exception as e:
self.logger.error(f"An unexpected error occurred during download: {e}", exc_info=True)
print(f"\n{COLOR_ERROR}An unexpected error occurred during download: {e}{COLOR_RESET}")
if temp_path.exists():
try:
temp_path.unlink()
except OSError as unlink_err:
self.logger.error(f"Failed to remove partial download {temp_path}: {unlink_err}")
return False
def _prepare_install_directory(self) -> bool:
"""
Ensures the target installation directory exists and is accessible.
Handles directory creation, prompting the user if outside $HOME.
Returns:
bool: True if the directory exists and is ready, False otherwise.
"""
if not self.install_path:
self.logger.error("Cannot prepare directory: install_path is not set.")
return False
self.logger.info(f"Preparing installation directory: {self.install_path}")
if self.install_path.exists():
if self.install_path.is_dir():
self.logger.info(f"Directory already exists: {self.install_path}")
if not os.access(self.install_path, os.W_OK | os.X_OK):
print(f"{COLOR_ERROR}Error: Directory exists but lacks necessary write/execute permissions.{COLOR_RESET}")
return False
return True
else:
print(f"{COLOR_ERROR}Error: The specified path exists but is a file, not a directory.{COLOR_RESET}")
return False
else:
self.logger.info("Directory does not exist. Attempting creation...")
try:
home_dir = Path.home()
is_outside_home = not str(self.install_path.resolve()).startswith(str(home_dir.resolve()))
if is_outside_home:
self.logger.warning(f"Install path {self.install_path} is outside home directory {home_dir}.")
print(f"\n{COLOR_PROMPT}The chosen path is outside your home directory and may require manual creation.{COLOR_RESET}")
while True:
response = input(f"{COLOR_PROMPT}Please create the directory \"{self.install_path}\" manually,\nensure you have write permissions, and then press Enter to continue (or 'q' to quit): {COLOR_RESET}").lower()
if response == 'q':
self.logger.warning("User aborted manual directory creation.")
return False
if self.install_path.exists():
if self.install_path.is_dir():
self.logger.info("Directory created manually by user.")
if not os.access(self.install_path, os.W_OK | os.X_OK):
print(f"{COLOR_WARNING}Warning: Directory created, but write/execute permissions might be missing.{COLOR_RESET}")
return True
else:
print(f"{COLOR_ERROR}Error: Path exists now, but it is not a directory. Please fix and try again.{COLOR_RESET}")
else:
print(f"\n{COLOR_ERROR}Directory still not found. Please create it or enter 'q' to quit.{COLOR_RESET}")
else:
self.logger.info("Path is inside home directory. Creating...")
os.makedirs(self.install_path)
self.logger.info(f"Successfully created directory: {self.install_path}")
if not os.access(self.install_path, os.W_OK | os.X_OK):
print(f"{COLOR_WARNING}Warning: Directory created, but lacks write/execute permissions. Subsequent steps might fail.{COLOR_RESET}")
return True
except PermissionError:
self.logger.error(f"Permission denied when trying to create directory: {self.install_path}", exc_info=True)
print(f"\n{COLOR_ERROR}Error: Permission denied creating directory.{COLOR_RESET}")
print(f"{COLOR_INFO}Please check permissions for the parent directory or choose a different location.{COLOR_RESET}")
return False
except OSError as e:
self.logger.error(f"Failed to create directory {self.install_path}: {e}", exc_info=True)
print(f"\n{COLOR_ERROR}Error creating directory: {e}{COLOR_RESET}")
return False
except Exception as e:
self.logger.error(f"An unexpected error occurred during directory preparation: {e}", exc_info=True)
print(f"\n{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}")
return False
def _get_wabbajack_install_path(self) -> Optional[Path]:
"""
Prompts the user for the Wabbajack installation path with tab completion.
Uses the FileSystemHandler for path validation and completion.
Returns:
Optional[Path]: The chosen installation path as a Path object, or None if cancelled.
"""
self.logger.info("Prompting for Wabbajack installation path.")
current_path = self.install_path if self.install_path else Path(DEFAULT_WABBAJACK_PATH).expanduser()
if READLINE_AVAILABLE and simple_path_completer:
readline.set_completer_delims(' \t\n;')
readline.parse_and_bind("tab: complete")
readline.set_completer(simple_path_completer)
try:
while True:
try:
prompt_text = f"{COLOR_PROMPT}Enter Wabbajack installation path (default: {current_path}): {COLOR_RESET}"
user_input = input(prompt_text).strip()
if not user_input:
chosen_path_str = str(current_path)
else:
chosen_path_str = user_input
chosen_path = Path(chosen_path_str).expanduser().resolve()
if not chosen_path.name:
print(f"{COLOR_ERROR}Invalid path. Please enter a valid directory path.{COLOR_RESET}")
continue
if chosen_path.exists() and not chosen_path.is_dir():
print(f"{COLOR_ERROR}Path exists but is not a directory: {chosen_path}{COLOR_RESET}")
continue
confirm_prompt = f"{COLOR_PROMPT}Install Wabbajack to {chosen_path}? (Y/n/c to cancel): {COLOR_RESET}"
confirmation = input(confirm_prompt).lower()
if confirmation == 'c':
self.logger.info("Wabbajack installation path selection cancelled by user.")
return None
elif confirmation != 'n':
self.install_path = chosen_path
self.logger.info(f"Wabbajack installation path set to: {self.install_path}")
return self.install_path
except KeyboardInterrupt:
self.logger.info("Wabbajack installation path selection cancelled by user (Ctrl+C).")
print("\nPath selection cancelled.")
return None
except Exception as e:
self.logger.error(f"Error during path input: {e}", exc_info=True)
print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}")
return None
finally:
if READLINE_AVAILABLE:
readline.set_completer(None)
def _get_wabbajack_shortcut_name(self) -> Optional[str]:
"""
Prompts the user for the Wabbajack shortcut name.
Returns:
Optional[str]: The name chosen by the user, or None if cancelled.
"""
self.logger.debug("Getting Wabbajack shortcut name.")
if self.shortcut_name:
self.logger.info(f"Using pre-configured shortcut name: {self.shortcut_name}")
return self.shortcut_name
chosen_name = DEFAULT_WABBAJACK_NAME
if self.menu_handler:
self.logger.debug("Using menu_handler for shortcut name input")
print(f"\nWabbajack Shortcut Name:")
name_input = self.menu_handler.get_input_with_default(
prompt=f"Enter the desired name for the Wabbajack Steam shortcut (default: {chosen_name})",
default=chosen_name
)
if name_input is not None:
self.logger.info(f"User provided shortcut name: {name_input}")
return name_input
else:
self.logger.info("User cancelled shortcut name input")
return None
try:
print(f"\n{COLOR_PROMPT}Enter the desired name for the Wabbajack Steam shortcut.{COLOR_RESET}")
name_input = input(f"{COLOR_PROMPT}Name [{chosen_name}]: {COLOR_RESET}").strip()
if not name_input:
self.logger.info(f"User did not provide input, using default name: {chosen_name}")
else:
chosen_name = name_input
self.logger.info(f"User provided name: {chosen_name}")
return chosen_name
except KeyboardInterrupt:
print(f"\n{COLOR_ERROR}Input cancelled by user.{COLOR_RESET}")
self.logger.warning("User cancelled name input.")
return None
except Exception as e:
self.logger.error(f"An unexpected error occurred while getting name input: {e}", exc_info=True)
return None
def _download_wabbajack_executable(self) -> bool:
"""
Downloads the latest Wabbajack.exe to the install directory.
Checks existence first.
Returns:
bool: True on success or if file exists, False otherwise.
"""
if not self.install_path:
self.logger.error("Cannot download Wabbajack.exe: install_path is not set.")
return False
url = "https://github.com/wabbajack-tools/wabbajack/releases/latest/download/Wabbajack.exe"
destination = self.install_path / "Wabbajack.exe"
if destination.is_file():
self.logger.info(f"Wabbajack.exe already exists at {destination}. Skipping download.")
return True
self.logger.info("Wabbajack.exe not found. Downloading...")
if self._download_file(url, destination):
try:
os.chmod(destination, 0o755)
self.logger.info(f"Set execute permissions on {destination}")
except Exception as e:
self.logger.warning(f"Could not set execute permission on {destination}: {e}")
self.logger.warning("Could not set execute permission on Wabbajack.exe.")
return True
else:
self.logger.error("Failed to download Wabbajack.exe.")
return False

File diff suppressed because it is too large Load Diff

View File

@@ -2,14 +2,9 @@
Wabbajack Installer Handler
Automated Wabbajack.exe installation and configuration via Proton.
Self-contained implementation inspired by Wabbajack-Proton-AuCu (MIT).
This handler provides:
- Automatic Wabbajack.exe download
- Steam shortcuts.vdf manipulation
- WebView2 installation
- Win7 registry configuration
- Optional Heroic GOG game detection
Provides: Wabbajack.exe download, Steam shortcuts.vdf handling,
WebView2 install, Win7 registry for compatibility, optional Heroic GOG detection.
"""
import json
@@ -271,28 +266,54 @@ class WabbajackInstallerHandler:
return None
def get_compat_data_path(self, app_id: int) -> Optional[Path]:
"""Get compatdata path for AppID"""
home = Path.home()
steam_paths = [
home / ".steam/steam",
home / ".local/share/Steam",
home / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
]
"""
Get compatdata path for AppID. Uses same detection logic as create_prefix_with_proton_wrapper.
for steam_path in steam_paths:
compat_path = steam_path / f"steamapps/compatdata/{app_id}"
if compat_path.parent.exists():
# Parent exists, so this is valid location even if prefix doesn't exist yet
return compat_path
Priority:
1. Check if prefix already exists at any known location
2. Use PathHandler library detection (Flatpak-aware via libraryfolders.vdf)
3. Fallback to native ~/.steam/steam
"""
from .path_handler import PathHandler
path_handler = PathHandler()
all_libraries = path_handler.get_all_steam_library_paths()
# Check if Flatpak Steam by looking for .var/app/com.valvesoftware.Steam in library paths
is_flatpak_steam = any('.var/app/com.valvesoftware.Steam' in str(lib) for lib in all_libraries)
# Determine compatdata root using same logic as create_prefix_with_proton_wrapper
if is_flatpak_steam and all_libraries:
# Flatpak Steam: use first library root (from libraryfolders.vdf)
library_root = all_libraries[0]
compatdata_dir = library_root / "steamapps/compatdata"
self.logger.debug(f"Flatpak Steam detected, using library root: {library_root}")
else:
# Native Steam
compatdata_dir = Path.home() / ".steam/steam/steamapps/compatdata"
self.logger.debug("Native Steam detected")
compat_path = compatdata_dir / str(app_id)
# Check if prefix already exists there
if compat_path.exists():
self.logger.debug(f"Found existing compatdata at: {compat_path}")
return compat_path
# Prefix doesn't exist yet - return expected path if compatdata root exists
if compatdata_dir.is_dir():
self.logger.debug(f"Using compatdata location: {compat_path}")
return compat_path
self.logger.warning(f"Compatdata root does not exist: {compatdata_dir}")
return None
def init_wine_prefix(self, app_id: int) -> Path:
def init_wine_prefix(self, app_id: int, proton_path: Optional[Path] = None) -> Path:
"""
Initialize Wine prefix using Proton.
Args:
app_id: Steam AppID
proton_path: Optional path to Proton directory; if None, uses Proton Experimental
Returns:
Path to created prefix
@@ -300,9 +321,9 @@ class WabbajackInstallerHandler:
Raises:
RuntimeError: If prefix creation fails
"""
proton_path = self.find_proton_experimental()
proton_path = proton_path or self.find_proton_experimental()
if not proton_path:
raise RuntimeError("Proton Experimental not found. Please install it from Steam.")
raise RuntimeError("Proton not found. Install a Proton version in Steam or set Install Proton in Settings.")
compat_data = self.get_compat_data_path(app_id)
if not compat_data:
@@ -318,10 +339,15 @@ class WabbajackInstallerHandler:
env = os.environ.copy()
env['STEAM_COMPAT_DATA_PATH'] = str(compat_data)
env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(compat_data.parent.parent.parent)
# Suppress GUI windows
env['DISPLAY'] = ''
env['WAYLAND_DISPLAY'] = ''
env['WINEDEBUG'] = '-all'
env['WINEDLLOVERRIDES'] = 'msdia80.dll=n;conhost.exe=d;cmd.exe=d'
self.logger.info(f"Initializing Wine prefix for AppID {app_id}...")
result = subprocess.run(
[str(proton_bin), 'run', 'wineboot'],
[str(proton_bin), 'run', 'wineboot', '-u'],
env=env,
capture_output=True,
text=True,
@@ -334,7 +360,7 @@ class WabbajackInstallerHandler:
self.logger.info(f"Prefix created: {prefix_path}")
return prefix_path
def run_in_prefix(self, app_id: int, exe_path: Path, args: List[str] = None) -> None:
def run_in_prefix(self, app_id: int, exe_path: Path, args: List[str] = None, proton_path: Optional[Path] = None) -> None:
"""
Run executable in Wine prefix using Proton.
@@ -342,13 +368,14 @@ class WabbajackInstallerHandler:
app_id: Steam AppID
exe_path: Path to executable
args: Optional command line arguments
proton_path: Optional path to Proton directory; if None, uses Proton Experimental
Raises:
RuntimeError: If execution fails
"""
proton_path = self.find_proton_experimental()
proton_path = proton_path or self.find_proton_experimental()
if not proton_path:
raise RuntimeError("Proton Experimental not found")
raise RuntimeError("Proton not found")
compat_data = self.get_compat_data_path(app_id)
if not compat_data:
@@ -362,8 +389,14 @@ class WabbajackInstallerHandler:
env = os.environ.copy()
env['STEAM_COMPAT_DATA_PATH'] = str(compat_data)
env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(compat_data.parent.parent.parent)
# Suppress Wine debug output
env['WINEDEBUG'] = '-all'
# Suppress cmd.exe and conhost.exe windows (the flickers you see)
# Keep DISPLAY so installers can run, but prevent console windows
env['WINEDLLOVERRIDES'] = 'msdia80.dll=n;conhost.exe=d;cmd.exe=d'
self.logger.info(f"Running {exe_path.name} in prefix...")
self.logger.debug(f"Command: {' '.join(cmd)}")
result = subprocess.run(
cmd,
env=env,
@@ -379,22 +412,24 @@ class WabbajackInstallerHandler:
if result.stdout:
error_msg += f"\nStdout: {result.stdout}"
self.logger.error(error_msg)
self.logger.debug(f"Full command output - returncode: {result.returncode}, stdout length: {len(result.stdout) if result.stdout else 0}, stderr length: {len(result.stderr) if result.stderr else 0}")
raise RuntimeError(error_msg)
def apply_registry(self, app_id: int, reg_content: str) -> None:
def apply_registry(self, app_id: int, reg_content: str, proton_path: Optional[Path] = None) -> None:
"""
Apply registry content to Wine prefix.
Args:
app_id: Steam AppID
reg_content: Registry file content
proton_path: Optional path to Proton directory; if None, uses Proton Experimental
Raises:
RuntimeError: If registry application fails
"""
proton_path = self.find_proton_experimental()
proton_path = proton_path or self.find_proton_experimental()
if not proton_path:
raise RuntimeError("Proton Experimental not found")
raise RuntimeError("Proton not found")
compat_data = self.get_compat_data_path(app_id)
if not compat_data:
@@ -434,13 +469,14 @@ class WabbajackInstallerHandler:
if temp_reg.exists():
temp_reg.unlink()
def install_webview2(self, app_id: int, install_folder: Path) -> None:
def install_webview2(self, app_id: int, install_folder: Path, proton_path: Optional[Path] = None) -> None:
"""
Download and install WebView2 runtime.
Args:
app_id: Steam AppID
install_folder: Directory to download installer to
proton_path: Optional path to Proton directory; if None, uses Proton Experimental
Raises:
RuntimeError: If installation fails
@@ -456,11 +492,18 @@ class WabbajackInstallerHandler:
self.logger.info(f"WebView2 installer path: {webview_installer}")
self.logger.info(f"AppID: {app_id}")
try:
self.run_in_prefix(app_id, webview_installer, ["/silent", "/install"])
self.run_in_prefix(app_id, webview_installer, ["/silent", "/install"], proton_path=proton_path)
self.logger.info("WebView2 installed successfully")
except RuntimeError as e:
error_str = str(e)
# Exit code 8 might mean "already installed" - log but don't fail
if "exit code 8" in error_str:
self.logger.warning(f"WebView2 installer returned exit code 8: {error_str}")
self.logger.warning("This may indicate WebView2 is already installed. Continuing...")
# Don't raise - treat as non-fatal
return
self.logger.error(f"WebView2 installation failed: {e}")
# Re-raise to let caller handle it
# Re-raise for other errors
raise
finally:
@@ -472,17 +515,18 @@ class WabbajackInstallerHandler:
except Exception as e:
self.logger.warning(f"Failed to cleanup WebView2 installer: {e}")
def apply_win7_registry(self, app_id: int) -> None:
def apply_win7_registry(self, app_id: int, proton_path: Optional[Path] = None) -> None:
"""
Apply Windows 7 registry settings.
Args:
app_id: Steam AppID
proton_path: Optional path to Proton directory; if None, uses Proton Experimental
Raises:
RuntimeError: If registry application fails
"""
self.apply_registry(app_id, self.WIN7_REGISTRY)
self.apply_registry(app_id, self.WIN7_REGISTRY, proton_path=proton_path)
def detect_heroic_gog_games(self) -> List[Dict]:
"""

View File

@@ -0,0 +1,347 @@
"""Prefix setup methods for InstallWabbajackHandler (Mixin)."""
import logging
import os
import pwd
import re
import shutil
import subprocess
import time
from pathlib import Path
from typing import Optional, Tuple
from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_PROMPT, COLOR_RESET
logger = logging.getLogger(__name__)
class WabbajackPrefixSetupMixin:
"""Mixin providing Wine prefix setup methods."""
def _find_steam_library_and_vdf_path(self) -> Tuple[Optional[Path], Optional[Path]]:
"""Finds the Steam library root and the path to the real libraryfolders.vdf."""
self.logger.info("Attempting to find Steam library and libraryfolders.vdf...")
try:
if isinstance(self.path_handler, type):
common_path = self.path_handler.find_steam_library()
else:
common_path = self.path_handler.find_steam_library()
if not common_path or not common_path.is_dir():
self.logger.error("Could not find Steam library common path.")
return None, None
library_root = common_path.parent.parent
self.logger.debug(f"Deduced library root: {library_root}")
vdf_path_candidates = [
library_root / 'config/libraryfolders.vdf',
library_root / '../config/libraryfolders.vdf'
]
real_vdf_path = None
for candidate in vdf_path_candidates:
resolved_candidate = candidate.resolve()
if resolved_candidate.is_file():
real_vdf_path = resolved_candidate
self.logger.info(f"Found real libraryfolders.vdf at: {real_vdf_path}")
break
if not real_vdf_path:
self.logger.error(f"Could not find libraryfolders.vdf within library root: {library_root}")
return None, None
return library_root, real_vdf_path
except Exception as e:
self.logger.error(f"Error finding Steam library/VDF: {e}", exc_info=True)
return None, None
def _link_steam_library_config(self) -> bool:
"""Creates the necessary directory structure and symlinks libraryfolders.vdf."""
if not self.compatdata_path:
self.logger.error("Cannot link Steam library: compatdata_path not set.")
return False
self.logger.info("Linking Steam library configuration (libraryfolders.vdf)...")
library_root, real_vdf_path = self._find_steam_library_and_vdf_path()
if not library_root or not real_vdf_path:
self.logger.error("Could not locate Steam library or libraryfolders.vdf.")
return False
target_dir = self.compatdata_path / 'pfx/drive_c/Program Files (x86)/Steam/config'
link_path = target_dir / 'libraryfolders.vdf'
try:
self.logger.debug(f"Backing up original libraryfolders.vdf: {real_vdf_path}")
if not self.filesystem_handler.backup_file(real_vdf_path):
self.logger.warning(f"Failed to backup {real_vdf_path}. Proceeding with caution.")
self.logger.warning("Failed to create backup of libraryfolders.vdf.")
self.logger.debug(f"Creating directory: {target_dir}")
os.makedirs(target_dir, exist_ok=True)
if link_path.is_symlink():
self.logger.debug(f"Removing existing symlink at {link_path}")
link_path.unlink()
elif link_path.exists():
self.logger.warning(f"Path {link_path} exists but is not a symlink. Removing it.")
if link_path.is_dir():
shutil.rmtree(link_path)
else:
link_path.unlink()
self.logger.info(f"Creating symlink from {real_vdf_path} to {link_path}")
os.symlink(real_vdf_path, link_path)
if link_path.is_symlink() and link_path.resolve() == real_vdf_path.resolve():
self.logger.info("Symlink created and verified successfully.")
return True
else:
self.logger.error("Symlink creation failed or verification failed.")
return False
except OSError as e:
self.logger.error(f"OSError during symlink creation: {e}", exc_info=True)
print(f"{COLOR_ERROR}Error creating Steam library link: {e}{COLOR_RESET}")
return False
except Exception as e:
self.logger.error(f"Unexpected error during symlink creation: {e}", exc_info=True)
print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}")
return False
def _create_prefix_library_vdf(self) -> bool:
"""Creates the necessary directory structure and copies a modified libraryfolders.vdf."""
if not self.compatdata_path:
self.logger.error("Cannot create prefix VDF: compatdata_path not set.")
return False
self.logger.info("Creating modified libraryfolders.vdf in prefix...")
library_root, real_vdf_path = self._find_steam_library_and_vdf_path()
if not real_vdf_path:
self.logger.error("Could not locate real libraryfolders.vdf.")
return False
self.logger.debug(f"Backing up original libraryfolders.vdf: {real_vdf_path}")
if not self.filesystem_handler.backup_file(real_vdf_path):
self.logger.warning(f"Failed to backup {real_vdf_path}. Proceeding with caution.")
self.logger.warning("Failed to create backup of libraryfolders.vdf.")
target_dir = self.compatdata_path / 'pfx/drive_c/Program Files (x86)/Steam/config'
target_vdf_path = target_dir / 'libraryfolders.vdf'
try:
self.logger.debug(f"Reading content from {real_vdf_path}")
vdf_content = real_vdf_path.read_text(encoding='utf-8')
path_pattern = re.compile(r'("path"\s*")([^"]+)(")')
def replace_path(match):
prefix, linux_path_str, suffix = match.groups()
self.logger.debug(f"Found path entry to convert: {linux_path_str}")
try:
linux_path = Path(linux_path_str)
if self.filesystem_handler.is_sd_card(linux_path):
relative_sd_path_str = self.filesystem_handler._strip_sdcard_path_prefix(linux_path)
wine_path = "D:\\" + relative_sd_path_str.replace('/', '\\')
self.logger.debug(f" Converted SD card path: {linux_path_str} -> {wine_path}")
else:
wine_path = "Z:\\" + linux_path_str.strip('/').replace('/', '\\')
self.logger.debug(f" Converted standard path: {linux_path_str} -> {wine_path}")
wine_path_vdf_escaped = wine_path.replace('\\', '\\\\')
return f'{prefix}{wine_path_vdf_escaped}{suffix}'
except Exception as e:
self.logger.error(f"Error converting path '{linux_path_str}': {e}. Keeping original.")
return match.group(0)
modified_content = path_pattern.sub(replace_path, vdf_content)
if modified_content != vdf_content:
self.logger.info("Successfully converted Linux paths to Wine paths in VDF content.")
else:
self.logger.warning("VDF content unchanged after conversion attempt. Did it contain Linux paths?")
self.logger.debug(f"Ensuring target directory exists: {target_dir}")
os.makedirs(target_dir, exist_ok=True)
self.logger.info(f"Writing modified VDF content to {target_vdf_path}")
target_vdf_path.write_text(modified_content, encoding='utf-8')
if target_vdf_path.is_file():
self.logger.info("Prefix libraryfolders.vdf created successfully.")
return True
else:
self.logger.error("Failed to create prefix libraryfolders.vdf.")
return False
except Exception as e:
self.logger.error(f"Error processing or writing prefix libraryfolders.vdf: {e}", exc_info=True)
print(f"{COLOR_ERROR}An error occurred configuring the Steam library in the prefix: {e}{COLOR_RESET}")
return False
def _create_dotnet_cache_dir(self) -> bool:
"""Creates the dotnet_bundle_extract cache directory."""
if not self.install_path:
self.logger.error("Cannot create dotnet cache dir: install_path not set.")
return False
try:
username = pwd.getpwuid(os.getuid()).pw_name
except Exception as e:
self.logger.error(f"Could not determine username: {e}")
self.logger.error("Could not determine username to create cache directory.")
return False
cache_dir = self.install_path / 'home' / username / '.cache' / 'dotnet_bundle_extract'
self.logger.info(f"Creating dotnet bundle cache directory: {cache_dir}")
try:
os.makedirs(cache_dir, exist_ok=True)
self.logger.info("dotnet cache directory created successfully.")
return True
except OSError as e:
self.logger.error(f"Failed to create dotnet cache directory {cache_dir}: {e}", exc_info=True)
print(f"{COLOR_ERROR}Error creating dotnet cache directory: {e}{COLOR_RESET}")
return False
except Exception as e:
self.logger.error(f"Unexpected error creating dotnet cache directory: {e}", exc_info=True)
print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}")
return False
def _check_and_prompt_flatpak_overrides(self):
"""Checks if Flatpak Steam needs filesystem overrides and prompts the user to apply them."""
self.logger.info("Checking for necessary Flatpak Steam filesystem overrides...")
is_flatpak_steam = False
if self.compatdata_path and ".var/app/com.valvesoftware.Steam" in str(self.compatdata_path):
is_flatpak_steam = True
self.logger.debug("Flatpak Steam detected based on compatdata path.")
if not is_flatpak_steam:
self.logger.info("Flatpak Steam not detected, skipping override check.")
return
paths_to_check = []
if self.install_path:
paths_to_check.append(self.install_path)
try:
all_libs = self.path_handler.get_all_steam_libraries()
paths_to_check.extend(all_libs)
except Exception as e:
self.logger.warning(f"Could not get all Steam libraries to check for overrides: {e}")
needed_overrides = set()
home_dir = Path.home()
flatpak_steam_data_dir = home_dir / ".var/app/com.valvesoftware.Steam"
for path in paths_to_check:
if not path:
continue
resolved_path = path.resolve()
is_outside_home = not str(resolved_path).startswith(str(home_dir))
is_outside_flatpak_data = not str(resolved_path).startswith(str(flatpak_steam_data_dir))
if is_outside_home and is_outside_flatpak_data:
parent_to_add = resolved_path.parent
while parent_to_add != parent_to_add.parent and len(str(parent_to_add)) > 1 and parent_to_add.name != 'home':
if parent_to_add.is_dir():
needed_overrides.add(str(parent_to_add))
self.logger.debug(f"Path {resolved_path} is outside sandbox. Adding parent {parent_to_add} to needed overrides.")
break
parent_to_add = parent_to_add.parent
if not needed_overrides:
self.logger.info("No external paths requiring Flatpak overrides detected.")
return
override_commands = []
for path_str in sorted(list(needed_overrides)):
override_commands.append(f"flatpak override --user --filesystem=\"{path_str}\" com.valvesoftware.Steam")
command_display = "\n".join([f" {cmd}" for cmd in override_commands])
print(f"\n{COLOR_PROMPT}--- Flatpak Steam Permissions ---{COLOR_RESET}")
print("Jackify has detected that you are using Flatpak Steam and have paths")
print("(e.g., Wabbajack install location or other Steam libraries) outside")
print("the standard Flatpak sandbox. For Wabbajack to access these locations,")
print("Steam needs the following filesystem permissions:")
print(f"{COLOR_INFO}{command_display}{COLOR_RESET}")
print("───────────────────────────────────────────────────────────────────")
try:
confirm = input(f"{COLOR_PROMPT}Do you want Jackify to apply these permissions now? (y/N): {COLOR_RESET}").lower().strip()
if confirm == 'y':
self.logger.info("User confirmed applying Flatpak overrides.")
success_count = 0
for cmd_str in override_commands:
self.logger.info(f"Executing: {cmd_str}")
try:
cmd_list = cmd_str.split()
result = subprocess.run(cmd_list, check=True, capture_output=True, text=True, timeout=30)
self.logger.debug(f"Override command successful: {result.stdout}")
success_count += 1
except FileNotFoundError:
print(f"{COLOR_ERROR}Error: 'flatpak' command not found. Cannot apply override.{COLOR_RESET}")
break
except subprocess.TimeoutExpired:
print(f"{COLOR_ERROR}Error: Flatpak override command timed out.{COLOR_RESET}")
except subprocess.CalledProcessError as e:
self.logger.error(f"Flatpak override failed: {cmd_str}. Error: {e.stderr}")
print(f"{COLOR_ERROR}Error applying override: {cmd_str}\n{e.stderr}{COLOR_RESET}")
except Exception as e:
self.logger.error(f"Unexpected error applying override {cmd_str}: {e}")
print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}")
if success_count == len(override_commands):
print(f"{COLOR_INFO}Successfully applied necessary Flatpak permissions.{COLOR_RESET}")
else:
print(f"{COLOR_ERROR}Applied {success_count}/{len(override_commands)} permissions. Some overrides may have failed. Check logs.{COLOR_RESET}")
else:
self.logger.info("User declined applying Flatpak overrides.")
print("Permissions not applied. You may need to run the override command(s) manually")
print("if Wabbajack has issues accessing files or game installations.")
except KeyboardInterrupt:
print("\nOperation cancelled by user.")
self.logger.warning("User cancelled during Flatpak override prompt.")
except Exception as e:
self.logger.error(f"Error during Flatpak override prompt/execution: {e}")
def _disable_prefix_decoration(self) -> bool:
"""Disables window manager decoration in the Wine prefix using protontricks -c."""
if not self.final_appid:
self.logger.error("Cannot disable decoration: final_appid not set.")
return False
self.logger.info(f"Disabling window manager decoration for AppID {self.final_appid} via -c 'wine reg add...'")
command = 'wine reg add "HKCU\\Software\\Wine\\X11 Driver" /v Decorated /t REG_SZ /d N /f'
try:
if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler:
self.logger.critical("ProtontricksHandler not initialized!")
self.logger.error("Internal Error: Protontricks handler not available.")
return False
result = self.protontricks_handler.run_protontricks(
'-c',
command,
self.final_appid
)
if result and result.returncode == 0:
self.logger.info("Successfully disabled window decoration (command returned 0).")
time.sleep(1)
return True
else:
err_msg = result.stderr if result else "Command execution failed or returned non-zero"
if result and not result.stderr and result.stdout:
err_msg += f"\nSTDOUT: {result.stdout}"
self.logger.error(f"Failed to disable window decoration via -c. Error: {err_msg}")
self.logger.error("Failed to disable window decoration via protontricks -c.")
return False
except Exception as e:
self.logger.error(f"Exception disabling window decoration: {e}", exc_info=True)
self.logger.error(f"Error disabling window decoration: {e}.")
return False

View File

@@ -0,0 +1,148 @@
"""Steam integration methods for InstallWabbajackHandler (Mixin)."""
import logging
import os
from .status_utils import clear_status, show_status
from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_PROMPT, COLOR_RESET
logger = logging.getLogger(__name__)
class WabbajackSteamIntegrationMixin:
"""Mixin providing Steam shortcut and restart methods."""
def _create_steam_shortcut(self) -> bool:
"""
Creates the Steam shortcut for Wabbajack using the ShortcutHandler.
Returns:
bool: True on success, False otherwise.
"""
if not self.shortcut_name or not self.install_path:
self.logger.error("Cannot create shortcut: Missing shortcut name or install path.")
return False
self.logger.info(f"Creating Steam shortcut '{self.shortcut_name}'...")
executable_path = str(self.install_path / "Wabbajack.exe")
from ..services.native_steam_service import NativeSteamService
steam_service = NativeSteamService()
success, app_id = steam_service.create_shortcut_with_proton(
app_name=self.shortcut_name,
exe_path=executable_path,
start_dir=os.path.dirname(executable_path),
launch_options="PROTON_USE_WINED3D=1 %command%",
tags=["Jackify", "Wabbajack"],
proton_version="proton_experimental"
)
if success and app_id:
self.initial_appid = app_id
self.logger.info(f"Shortcut created successfully with initial AppID: {self.initial_appid}")
return True
else:
self.logger.error("Failed to create Steam shortcut via ShortcutHandler.")
print(f"{COLOR_ERROR}Error: Failed to create the Steam shortcut for Wabbajack.{COLOR_RESET}")
return False
def _display_manual_proton_steps(self):
"""Displays the detailed manual steps required for Proton setup."""
if not self.shortcut_name:
self.logger.error("Cannot display manual steps: shortcut_name not set.")
self.logger.error("Internal Error: Shortcut name missing.")
return
print(f"\n{COLOR_PROMPT}--- Manual Proton Setup Required ---{COLOR_RESET}")
print("Please complete the following steps in Steam:")
print(f" 1. Locate the '{COLOR_INFO}{self.shortcut_name}{COLOR_RESET}' entry in your Steam Library")
print(" 2. Right-click and select 'Properties'")
print(" 3. Switch to the 'Compatibility' tab")
print(" 4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'")
print(" 5. Select 'Proton - Experimental' from the dropdown menu")
print(" 6. Close the Properties window")
print(f" 7. Launch '{COLOR_INFO}{self.shortcut_name}{COLOR_RESET}' from your Steam Library")
print(" 8. Wait for Wabbajack to download its files and fully load")
print(" 9. Once Wabbajack has fully loaded, CLOSE IT completely and return here")
print(f"{COLOR_PROMPT}------------------------------------{COLOR_RESET}")
def _handle_steam_restart_and_manual_steps(self) -> bool:
"""Handles Steam restart and manual steps prompt, but no extra confirmation."""
self.logger.info("Handling Steam restart and manual steps prompt.")
clear_status()
print("\n───────────────────────────────────────────────────────────────────")
print(f"{COLOR_INFO}Manual Steps Required:{COLOR_RESET} After Steam restarts, follow the on-screen instructions to set Proton Experimental.")
print("───────────────────────────────────────────────────────────────────")
self.logger.info("Attempting secure Steam restart...")
show_status("Restarting Steam")
if not hasattr(self, 'shortcut_handler') or not self.shortcut_handler:
self.logger.critical("ShortcutHandler not initialized in InstallWabbajackHandler!")
self.logger.error("Internal Error: Shortcut handler not available for restart.")
return False
if self.shortcut_handler.secure_steam_restart():
self.logger.info("Secure Steam restart successful.")
clear_status()
self._display_manual_proton_steps()
print()
input(f"{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}")
self.logger.info("User confirmed completion of manual steps.")
return True
else:
self.logger.error("Secure Steam restart failed.")
clear_status()
print(f"\n{COLOR_ERROR}Error: Steam restart failed.{COLOR_RESET}")
print("Please try restarting Steam manually:")
print("1. Exit Steam completely (Steam -> Exit or right-click tray icon -> Exit)")
print("2. Wait a few seconds")
print("3. Start Steam again")
print("\nAfter restarting, you MUST perform the manual Proton setup steps:")
self._display_manual_proton_steps()
print(f"\n{COLOR_ERROR}You will need to re-run this Jackify option after completing these steps.{COLOR_RESET}")
print("───────────────────────────────────────────────────────────────────")
return False
def _redetect_appid(self) -> bool:
"""
Re-detects the AppID for the shortcut after Steam restart.
Returns:
bool: True if AppID is found, False otherwise.
"""
if not self.shortcut_name:
self.logger.error("Cannot redetect AppID: shortcut_name not set.")
return False
self.logger.info(f"Re-detecting AppID for shortcut '{self.shortcut_name}'...")
try:
if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler:
self.logger.critical("ProtontricksHandler not initialized in InstallWabbajackHandler!")
self.logger.error("Internal Error: Protontricks handler not available.")
return False
all_shortcuts = self.protontricks_handler.list_non_steam_shortcuts()
if not all_shortcuts:
self.logger.error("Protontricks listed no non-Steam shortcuts.")
return False
found_appid = None
for name, appid in all_shortcuts.items():
if name.lower() == self.shortcut_name.lower():
found_appid = appid
break
if found_appid:
self.final_appid = found_appid
self.logger.info(f"Successfully re-detected AppID: {self.final_appid}")
if self.initial_appid and self.initial_appid != self.final_appid:
self.logger.info(f"AppID changed after Steam restart: {self.initial_appid} -> {self.final_appid}")
elif not self.initial_appid:
self.logger.warning("Initial AppID was not set, cannot compare.")
return True
else:
self.logger.error(f"Shortcut '{self.shortcut_name}' not found in protontricks list after restart.")
return False
except Exception as e:
self.logger.error(f"Error re-detecting AppID: {e}", exc_info=True)
return False

View File

@@ -0,0 +1,151 @@
"""Verification methods for InstallWabbajackHandler (Mixin)."""
import logging
import shutil
from pathlib import Path
from typing import Optional
from .status_utils import clear_status, show_status
from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_RESET
logger = logging.getLogger(__name__)
class WabbajackVerificationMixin:
"""Mixin providing verification and validation methods."""
def _find_steam_config_vdf(self) -> Optional[Path]:
"""Finds the path to the primary Steam config.vdf file."""
self.logger.debug("Searching for Steam config.vdf...")
common_paths = [
Path.home() / ".steam/steam/config/config.vdf",
Path.home() / ".local/share/Steam/config/config.vdf",
Path.home() / ".var/app/com.valvesoftware.Steam/.config/Valve Corporation/Steam/config/config.vdf"
]
for path in common_paths:
if path.is_file():
self.logger.info(f"Found config.vdf at: {path}")
return path
self.logger.error("Could not find Steam config.vdf in common locations.")
return None
def _verify_manual_steps(self) -> bool:
"""
Verifies that the user has performed the manual steps using ModlistHandler.
Checks AppID, Proton version set, and prefix existence.
Returns:
bool: True if verification passes AND compatdata_path is set, False otherwise.
"""
self.logger.info("Verifying manual Proton setup steps...")
self.compatdata_path = None
clear_status()
if not self._redetect_appid():
print(f"{COLOR_ERROR}Error: Could not find the Steam shortcut '{self.shortcut_name}' using protontricks.{COLOR_RESET}")
print(f"{COLOR_INFO}Ensure Steam has restarted and the shortcut is visible.{COLOR_RESET}")
return False
self.logger.debug(f"Verification using final AppID: {self.final_appid}")
show_status("Verifying Proton Setup")
if not hasattr(self, 'modlist_handler') or not self.modlist_handler:
self.logger.critical("ModlistHandler not initialized in InstallWabbajackHandler!")
self.logger.error("Internal Error: Modlist handler not available for verification.")
return False
verified, status_code = self.modlist_handler.verify_proton_setup(self.final_appid)
if not verified:
if status_code == 'wrong_proton_version':
proton_ver = getattr(self.modlist_handler, 'proton_ver', 'Unknown')
print(f"{COLOR_ERROR}\nVerification Failed: Incorrect Proton version detected ('{proton_ver}'). Expected 'Proton Experimental' (or similar).{COLOR_RESET}")
print(f"{COLOR_INFO}Please ensure you selected the correct Proton version in the shortcut's Compatibility properties.{COLOR_RESET}")
elif status_code == 'proton_check_failed':
print(f"{COLOR_ERROR}\nVerification Failed: Compatibility tool not detected as set for '{self.shortcut_name}' in Steam config.{COLOR_RESET}")
print(f"{COLOR_INFO}Please ensure you forced a Proton version in the shortcut's Compatibility properties.{COLOR_RESET}")
elif status_code == 'compatdata_missing':
print(f"{COLOR_ERROR}\nVerification Failed: Steam compatdata directory for AppID {self.final_appid} not found.{COLOR_RESET}")
print(f"{COLOR_INFO}Have you launched the shortcut '{self.shortcut_name}' at least once after setting Proton?{COLOR_RESET}")
elif status_code == 'prefix_missing':
print(f"{COLOR_ERROR}\nVerification Failed: Wine prefix directory (pfx) not found inside compatdata.{COLOR_RESET}")
print(f"{COLOR_INFO}This usually means the shortcut hasn't been launched successfully after setting Proton.{COLOR_RESET}")
elif status_code == 'config_vdf_missing' or status_code == 'config_vdf_error':
print(f"{COLOR_ERROR}\nVerification Failed: Could not read or parse Steam's config.vdf file ({status_code}).{COLOR_RESET}")
print(f"{COLOR_INFO}Check file permissions or integrity. Check logs for details.{COLOR_RESET}")
else:
print(f"{COLOR_ERROR}\nVerification Failed: An unexpected error occurred ({status_code}). Check logs.{COLOR_RESET}")
return False
self.logger.info("Basic verification checks passed. Confirming compatdata path...")
modlist_handler_compat_path = getattr(self.modlist_handler, 'compat_data_path', None)
if modlist_handler_compat_path:
self.compatdata_path = modlist_handler_compat_path
self.logger.info(f"Compatdata path obtained from ModlistHandler: {self.compatdata_path}")
else:
self.logger.info("ModlistHandler did not set compat_data_path. Attempting manual lookup via PathHandler.")
if not hasattr(self, 'path_handler') or not self.path_handler:
self.logger.critical("PathHandler not initialized in InstallWabbajackHandler!")
self.logger.error("Internal Error: Path handler not available for verification.")
return False
self.compatdata_path = self.path_handler.find_compat_data(self.final_appid)
if self.compatdata_path:
self.logger.info(f"Manually found compatdata path via PathHandler: {self.compatdata_path}")
else:
self.logger.error("Verification checks passed, but COULD NOT FIND compatdata path via ModlistHandler or PathHandler.")
print(f"\n{COLOR_ERROR}Verification Error: Basic checks passed, but failed to locate the compatdata directory for AppID {self.final_appid}.{COLOR_RESET}")
print(f"{COLOR_INFO}This is unexpected. Check Steam filesystem structure and logs.{COLOR_RESET}")
return False
self.logger.info("Manual steps verification successful (including path confirmation).")
logger.info(f"Verification successful! (AppID: {self.final_appid}, Path: {self.compatdata_path})")
return True
def _backup_and_replace_final_reg_files(self) -> bool:
"""Backs up current reg files and replaces them with the final downloaded versions."""
if not self.compatdata_path:
self.logger.error("Cannot backup/replace reg files: compatdata_path not set.")
return False
pfx_path = self.compatdata_path / 'pfx'
system_reg = pfx_path / 'system.reg'
user_reg = pfx_path / 'user.reg'
system_reg_bak = pfx_path / 'system.reg.orig'
user_reg_bak = pfx_path / 'user.reg.orig'
self.logger.info("Backing up existing registry files...")
logger.info("Backing up current registry files...")
try:
if system_reg.exists():
shutil.copy2(system_reg, system_reg_bak)
self.logger.debug(f"Backed up {system_reg} to {system_reg_bak}")
else:
self.logger.warning(f"Original {system_reg} not found for backup.")
if user_reg.exists():
shutil.copy2(user_reg, user_reg_bak)
self.logger.debug(f"Backed up {user_reg} to {user_reg_bak}")
else:
self.logger.warning(f"Original {user_reg} not found for backup.")
except Exception as e:
self.logger.error(f"Error backing up registry files: {e}", exc_info=True)
print(f"{COLOR_ERROR}Error backing up registry files: {e}{COLOR_RESET}")
return False
final_system_reg_url = "https://github.com/Omni-guides/Wabbajack-Modlist-Linux/raw/refs/heads/main/files/system.reg.github"
final_user_reg_url = "https://github.com/Omni-guides/Wabbajack-Modlist-Linux/raw/refs/heads/main/files/user.reg.github"
logger.info("Downloading and applying final registry settings...")
system_ok = self._download_and_replace_reg_file(final_system_reg_url, system_reg)
user_ok = self._download_and_replace_reg_file(final_user_reg_url, user_reg)
if system_ok and user_ok:
self.logger.info("Successfully applied final registry files.")
return True
else:
self.logger.error("Failed to download or replace one or both final registry files.")
self.logger.error("Failed to apply final registry settings.")
return False

View File

@@ -0,0 +1,140 @@
"""WebView installation methods for InstallWabbajackHandler (Mixin)."""
import logging
import shutil
import subprocess
from pathlib import Path
from .status_utils import show_status
logger = logging.getLogger(__name__)
class WabbajackWebViewMixin:
"""Mixin providing WebView installation methods."""
def _install_webview(self) -> bool:
"""Installs the WebView2 runtime using protontricks-launch."""
if not self.final_appid or not self.install_path:
self.logger.error("Cannot install WebView: final_appid or install_path not set.")
return False
installer_name = "MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe"
installer_path = self.install_path / installer_name
if not installer_path.is_file():
self.logger.error(f"WebView installer not found at {installer_path}. Cannot install.")
self.logger.error("WebView installer file missing. Please ensure step 12 completed.")
return False
self.logger.info(f"Starting WebView installation for AppID {self.final_appid}...")
cmd_prefix = []
if self.protontricks_handler.which_protontricks == 'flatpak':
cmd_prefix = ["flatpak", "run", "--command=protontricks-launch", "com.github.Matoking.protontricks"]
else:
launch_path = shutil.which("protontricks-launch")
if not launch_path:
self.logger.error("protontricks-launch command not found in PATH.")
self.logger.error("protontricks-launch command not found.")
return False
cmd_prefix = [launch_path]
args = ["--appid", self.final_appid, str(installer_path), "/silent", "/install"]
full_cmd = cmd_prefix + args
self.logger.debug(f"Executing WebView install command: {' '.join(full_cmd)}")
try:
result = subprocess.run(full_cmd, check=True, capture_output=True, text=True, timeout=600)
self.logger.info("WebView installation command completed successfully.")
return True
except FileNotFoundError:
self.logger.error(f"Command not found: {cmd_prefix[0]}")
self.logger.error(f"Could not execute {cmd_prefix[0]}. Is it installed correctly?")
return False
except subprocess.TimeoutExpired:
self.logger.error("WebView installation timed out after 10 minutes.")
self.logger.error("WebView installation took too long and timed out.")
return False
except subprocess.CalledProcessError as e:
self.logger.error(f"WebView installation failed with return code {e.returncode}")
self.logger.error(f"STDERR (truncated):\n{e.stderr[:500] if e.stderr else ''}")
self.logger.error(f"WebView installation failed (Return Code: {e.returncode}). Check logs for details.")
return False
except Exception as e:
self.logger.error(f"Unexpected error during WebView installation: {e}", exc_info=True)
self.logger.error(f"An unexpected error occurred during WebView installation: {e}")
return False
def _download_webview_installer(self) -> bool:
"""
Downloads the specific WebView2 installer needed by Wabbajack.
Checks existence first.
Returns:
bool: True on success or if file already exists correctly, False otherwise.
"""
if not self.install_path:
self.logger.error("Cannot download WebView installer: install_path is not set.")
return False
url = "https://node10.sokloud.com/filebrowser/api/public/dl/yqVTbUT8/rwatch/WebView/MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe"
file_name = "MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe"
destination = self.install_path / file_name
self.logger.info(f"Checking WebView installer: {destination}")
if destination.is_file():
self.logger.info(f"WebView installer {destination.name} already exists. Skipping download.")
return True
self.logger.info(f"WebView installer not found locally. Downloading {file_name}...")
show_status("Downloading WebView Installer")
if self._download_file(url, destination):
return True
else:
self.logger.error(f"Failed to download WebView installer from {url}.")
return False
def _set_prefix_renderer(self, renderer: str = 'vulkan') -> bool:
"""Sets the prefix renderer using protontricks."""
if not self.final_appid:
self.logger.error("Cannot set renderer: final_appid not set.")
return False
self.logger.info(f"Setting prefix renderer to {renderer} for AppID {self.final_appid}...")
try:
if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler:
self.logger.critical("ProtontricksHandler not initialized in InstallWabbajackHandler!")
self.logger.error("Internal Error: Protontricks handler not available.")
return False
result = self.protontricks_handler.run_protontricks(
self.final_appid,
'settings',
f'renderer={renderer}'
)
if result and result.returncode == 0:
self.logger.info(f"Successfully set renderer to {renderer}.")
return True
else:
err_msg = result.stderr if result else "Command execution failed"
self.logger.error(f"Failed to set renderer to {renderer}. Error: {err_msg}")
self.logger.error(f"Failed to set prefix renderer to {renderer}.")
return False
except Exception as e:
self.logger.error(f"Exception setting renderer: {e}", exc_info=True)
self.logger.error(f"Error setting prefix renderer: {e}.")
return False
def _download_and_replace_reg_file(self, url: str, target_reg_path: Path) -> bool:
"""Downloads a .reg file and replaces the target file. Always downloads and overwrites."""
self.logger.info(f"Downloading registry file from {url} to replace {target_reg_path}")
if self._download_file(url, target_reg_path):
self.logger.info(f"Successfully downloaded and replaced {target_reg_path}")
return True
else:
self.logger.error(f"Failed to download/replace {target_reg_path} from {url}")
return False

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Post-install and modlist config mixin for WineUtils.
Extracted from wine_utils for file-size and domain separation.
"""
import os
import re
import subprocess
import logging
from typing import Optional
logger = logging.getLogger(__name__)
class WineUtilsConfigMixin:
"""Mixin providing post-install tasks and modlist-specific configuration."""
@staticmethod
def create_dxvk_file(modlist_dir: str, modlist_sdcard: bool, steam_library: str,
basegame_sdcard: bool, game_var_full: str) -> bool:
"""Create DXVK file in the modlist directory pointing to the game directory."""
try:
game_dir = os.path.join(steam_library, game_var_full)
dxvk_file = os.path.join(modlist_dir, "DXVK")
with open(dxvk_file, 'w') as f:
f.write(game_dir)
logger.debug(f"Created DXVK file at {dxvk_file} pointing to {game_dir}")
return True
except Exception as e:
logger.error(f"Error creating DXVK file: {e}")
return False
@staticmethod
def small_additional_tasks(modlist_dir: str, compat_data_path: Optional[str]) -> bool:
"""Perform small additional tasks (delete unsupported plugins, download Bethini font)."""
try:
file_to_delete = os.path.join(modlist_dir, "plugins/FixGameRegKey.py")
if os.path.exists(file_to_delete):
os.remove(file_to_delete)
logger.debug(f"File deleted: {file_to_delete}")
if compat_data_path and os.path.isdir(compat_data_path):
font_path = os.path.join(compat_data_path, "pfx/drive_c/windows/Fonts/seguisym.ttf")
font_dir = os.path.dirname(font_path)
os.makedirs(font_dir, exist_ok=True)
font_url = "https://github.com/mrbvrz/segoe-ui-linux/raw/refs/heads/master/font/seguisym.ttf"
subprocess.run(
f"wget {font_url} -q -nc -O \"{font_path}\"",
shell=True,
check=True
)
logger.debug(f"Downloaded font to: {font_path}")
return True
except Exception as e:
logger.error(f"Error performing additional tasks: {e}")
return False
@staticmethod
def modlist_specific_steps(modlist: str, appid: str) -> bool:
"""Perform modlist-specific configuration steps. Returns True on success."""
try:
modlist_configs = {
"wildlander": ["dotnet48", "dotnet472", "vcrun2019"],
"septimus|sigernacollection|licentia|aldrnari|phoenix": ["dotnet48", "dotnet472"],
"masterstroke": ["dotnet48", "dotnet472"],
"diablo": ["dotnet48", "dotnet472"],
"living_skyrim": ["dotnet48", "dotnet472", "dotnet462"],
"nolvus": ["dotnet8"]
}
modlist_lower = modlist.lower().replace(" ", "")
if "wildlander" in modlist_lower:
logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!")
return True
for pattern, components in modlist_configs.items():
if re.search(pattern.replace("|", "|.*"), modlist_lower):
logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!")
for component in components:
if component == "dotnet8":
logger.info("Downloading .NET 8 Runtime")
pass
else:
logger.info(f"Installing {component}...")
pass
return True
logger.debug(f"No specific steps needed for {modlist}")
return True
except Exception as e:
logger.error(f"Error performing modlist-specific steps: {e}")
return False
@staticmethod
def fnv_launch_options(game_var: str, compat_data_path: Optional[str], modlist: str) -> bool:
"""Set up Fallout New Vegas launch options. Returns True on success."""
if game_var != "Fallout New Vegas":
return True
try:
appid_to_check = "22380"
for path in [
os.path.expanduser("~/.local/share/Steam/steamapps/compatdata"),
os.path.expanduser("~/.steam/steam/steamapps/compatdata"),
os.path.expanduser("~/.steam/root/steamapps/compatdata")
]:
compat_path = os.path.join(path, appid_to_check)
if os.path.exists(compat_path):
logger.warning(
f"\nFor {modlist}, please add the following line to the Launch Options "
f"in Steam for your '{modlist}' entry:"
)
logger.info(f"\nSTEAM_COMPAT_DATA_PATH=\"{compat_path}\" %command%")
logger.warning("\nThis is essential for the modlist to load correctly.")
return True
logger.error("Could not determine the compatdata path for Fallout New Vegas")
return False
except Exception as e:
logger.error(f"Error setting FNV launch options: {e}")
return False

View File

@@ -0,0 +1,482 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Proton scanning and selection mixin for WineUtils.
Extracted from wine_utils for file-size and domain separation.
"""
import os
import re
import logging
from pathlib import Path
from typing import Optional, Tuple, List, Dict, Any
logger = logging.getLogger(__name__)
VALVE_PROTON_APPID_MAP = {
'2805730': 'proton_9',
'3658110': 'proton_10',
'1493710': 'proton_experimental',
'2180100': 'proton_hotfix',
'1887720': 'proton_8',
}
class WineUtilsProtonMixin:
"""Mixin providing Proton scanning, selection, and path resolution."""
@staticmethod
def get_proton_version(compat_data_path: str) -> str:
"""
Detect the Proton version used by a Steam game/shortcut.
Args:
compat_data_path: Path to the compatibility data directory.
Returns:
Detected Proton version or 'Unknown' if not found.
"""
logger.info("Detecting Proton version...")
if not os.path.isdir(compat_data_path):
logger.warning(f"Compatdata directory not found at '{compat_data_path}'")
return "Unknown"
system_reg_path = os.path.join(compat_data_path, "pfx", "system.reg")
if os.path.isfile(system_reg_path):
try:
with open(system_reg_path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
match = re.search(r'"SteamClientProtonVersion"="([^"]+)"', content)
if match:
version = match.group(1).strip()
proton_ver = version if "GE" in version else f"Proton {version}"
logger.debug(f"Detected Proton version from registry: {proton_ver}")
return proton_ver
except Exception as e:
logger.debug(f"Error reading system.reg: {e}")
config_info_path = os.path.join(compat_data_path, "config_info")
if os.path.isfile(config_info_path):
try:
with open(config_info_path, "r") as f:
config_ver = f.readline().strip()
if config_ver:
proton_ver = config_ver if "GE" in config_ver else f"Proton {config_ver}"
logger.debug(f"Detected Proton version from config_info: {proton_ver}")
return proton_ver
except Exception as e:
logger.debug(f"Error reading config_info: {e}")
logger.warning("Could not detect Proton version")
return "Unknown"
@staticmethod
def find_proton_binary(proton_version: str) -> Optional[str]:
"""
Find the full path to the Proton binary given a version string.
Returns the path to 'files/bin/wine', or None if not found.
"""
version_patterns = [proton_version, proton_version.replace(' ', '_'), proton_version.replace(' ', '')]
steam_common_paths = []
compatibility_paths = []
try:
from .path_handler import PathHandler
root_steam_libs = PathHandler.get_all_steam_library_paths()
for lib_path in root_steam_libs:
lib = Path(lib_path)
if lib.exists():
common_path = lib / "steamapps/common"
if common_path.exists():
steam_common_paths.append(common_path)
compatibility_paths.append(lib / "compatibilitytools.d")
except Exception as e:
logger.warning(f"Could not detect Steam libraries from libraryfolders.vdf: {e}")
if not steam_common_paths:
steam_common_paths = [
Path.home() / ".steam/steam/steamapps/common",
Path.home() / ".local/share/Steam/steamapps/common",
Path.home() / ".steam/root/steamapps/common"
]
if not compatibility_paths:
compatibility_paths = [
Path.home() / ".steam/steam/compatibilitytools.d",
Path.home() / ".local/share/Steam/compatibilitytools.d"
]
compatibility_paths.extend([
Path.home() / ".steam/root/compatibilitytools.d",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d",
Path.home() / ".var/app/com.valvesoftware.Steam.CompatibilityTool.Proton-GE/.local/share/Steam/compatibilitytools.d",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d/GE-Proton"
])
if proton_version.strip().startswith("Proton 9"):
proton9_candidates = ["Proton 9.0", "Proton 9.0 (Beta)"]
for base_path in steam_common_paths:
for name in proton9_candidates:
candidate = base_path / name / "files/bin/wine"
if candidate.is_file():
return str(candidate)
for subdir in base_path.glob("Proton 9*"):
wine_bin = subdir / "files/bin/wine"
if wine_bin.is_file():
return str(wine_bin)
all_paths = steam_common_paths + compatibility_paths
for base_path in all_paths:
if not base_path.is_dir():
continue
for pattern in version_patterns:
proton_dir = base_path / pattern
wine_bin = proton_dir / "files/bin/wine"
if wine_bin.is_file():
return str(wine_bin)
for subdir in base_path.glob(f"*{pattern}*"):
wine_bin = subdir / "files/bin/wine"
if wine_bin.is_file():
return str(wine_bin)
try:
from .config_handler import ConfigHandler
config = ConfigHandler()
fallback_path = config.get_proton_path()
if fallback_path != 'auto':
fallback_wine_bin = Path(fallback_path) / "files/bin/wine"
if fallback_wine_bin.is_file():
logger.warning(f"Requested Proton version '{proton_version}' not found. Falling back to user's configured version.")
return str(fallback_wine_bin)
except Exception:
pass
for base_path in steam_common_paths:
wine_bin = base_path / "Proton - Experimental" / "files/bin/wine"
if wine_bin.is_file():
logger.warning(f"Requested Proton version '{proton_version}' not found. Falling back to 'Proton - Experimental'.")
return str(wine_bin)
return None
@staticmethod
def get_proton_paths(appid: str) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""
Get the Proton paths for a given AppID.
Returns (compatdata_path, proton_path, wine_bin) or (None, None, None) if not found.
"""
logger.info(f"Getting Proton paths for AppID {appid}")
possible_compat_bases = [
Path.home() / ".steam/steam/steamapps/compatdata",
Path.home() / ".local/share/Steam/steamapps/compatdata"
]
compatdata_path = None
for base_path in possible_compat_bases:
potential_compat_path = base_path / appid
if potential_compat_path.is_dir():
compatdata_path = str(potential_compat_path)
logger.debug(f"Found compatdata directory: {compatdata_path}")
break
if not compatdata_path:
logger.error(f"Could not find compatdata directory for AppID {appid}")
return None, None, None
proton_version = WineUtilsProtonMixin.get_proton_version(compatdata_path)
if proton_version == "Unknown":
logger.error(f"Could not determine Proton version for AppID {appid}")
return None, None, None
wine_bin = WineUtilsProtonMixin.find_proton_binary(proton_version)
if not wine_bin:
logger.error(f"Could not find Proton binary for version {proton_version}")
return None, None, None
proton_path = str(Path(wine_bin).parent.parent)
logger.debug(f"Found Proton path: {proton_path}")
return compatdata_path, proton_path, wine_bin
@staticmethod
def get_steam_library_paths() -> List[Path]:
"""Get all Steam library paths from libraryfolders.vdf."""
steam_common_paths = []
try:
from .path_handler import PathHandler
library_paths = PathHandler.get_all_steam_library_paths()
logger.info(f"PathHandler found Steam libraries: {library_paths}")
for lib_path in library_paths:
common_path = lib_path / "steamapps" / "common"
if common_path.exists():
steam_common_paths.append(common_path)
logger.debug(f"Added Steam library: {common_path}")
else:
logger.debug(f"Steam library path doesn't exist: {common_path}")
except Exception as e:
logger.error(f"PathHandler failed to read libraryfolders.vdf: {e}")
fallback_paths = [
Path.home() / ".steam/steam/steamapps/common",
Path.home() / ".local/share/Steam/steamapps/common",
Path.home() / ".steam/root/steamapps/common"
]
for fallback_path in fallback_paths:
if fallback_path.exists() and fallback_path not in steam_common_paths:
steam_common_paths.append(fallback_path)
logger.debug(f"Added fallback Steam library: {fallback_path}")
logger.info(f"Final Steam library paths for Proton scanning: {steam_common_paths}")
return steam_common_paths
@staticmethod
def get_compatibility_tool_paths() -> List[Path]:
"""Get all compatibility tool paths for GE-Proton and other custom Proton versions."""
compat_paths = [
Path.home() / ".steam/steam/compatibilitytools.d",
Path.home() / ".local/share/Steam/compatibilitytools.d",
Path.home() / ".steam/root/compatibilitytools.d",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d",
Path.home() / ".var/app/com.valvesoftware.Steam.CompatibilityTool.Proton-GE/.local/share/Steam/compatibilitytools.d",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d/GE-Proton"
]
return [path for path in compat_paths if path.exists()]
@staticmethod
def _parse_compat_tool_name(proton_dir: Path) -> Optional[str]:
"""Parse the Steam internal name from a compatibilitytool.vdf file."""
vdf_path = proton_dir / "compatibilitytool.vdf"
if not vdf_path.exists():
return None
try:
with open(vdf_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
match = re.search(r'"compat_tools"\s*\{[^{]*"([^"]+)"\s*(?://[^\n]*)?\s*\{', content, re.DOTALL)
if match:
return match.group(1)
except Exception as e:
logger.warning(f"Failed to parse {vdf_path}: {e}")
return None
@staticmethod
def _find_valve_proton_appid(proton_dir_name: str) -> Optional[str]:
"""Find the Steam App ID for a Valve Proton by matching appmanifest installdir."""
steam_libs = WineUtilsProtonMixin.get_steam_library_paths()
for lib_path in steam_libs:
steamapps_dir = lib_path.parent
for manifest in steamapps_dir.glob("appmanifest_*.acf"):
try:
with open(manifest, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
installdir_match = re.search(r'"installdir"\s+"([^"]+)"', content)
appid_match = re.search(r'"appid"\s+"(\d+)"', content)
if installdir_match and appid_match and installdir_match.group(1) == proton_dir_name:
return appid_match.group(1)
except Exception:
continue
return None
@staticmethod
def resolve_steam_compat_name(proton_path: Any) -> Optional[str]:
"""
Resolve the correct Steam config.vdf internal name for a Proton installation.
Returns internal name for CompatToolMapping, or None if unresolvable.
"""
proton_path = Path(proton_path)
if not proton_path.is_dir():
logger.warning(f"Proton path not found: {proton_path}")
return None
compat_name = WineUtilsProtonMixin._parse_compat_tool_name(proton_path)
if compat_name:
logger.debug(f"Resolved compat name from vdf: {proton_path.name} -> {compat_name}")
return compat_name
dir_name = proton_path.name
appid = WineUtilsProtonMixin._find_valve_proton_appid(dir_name)
if appid and appid in VALVE_PROTON_APPID_MAP:
name = VALVE_PROTON_APPID_MAP[appid]
logger.debug(f"Resolved Valve Proton: {dir_name} (AppID {appid}) -> {name}")
return name
if dir_name.startswith('GE-Proton'):
return dir_name
logger.warning(f"Could not resolve Steam compat name for: {proton_path}")
return None
@staticmethod
def scan_thirdparty_proton_versions() -> List[Dict[str, Any]]:
"""Scan for non-GE third-party Proton versions in compatibilitytools.d directories."""
logger.info("Scanning for third-party Proton versions...")
found_versions = []
seen_names = set()
compat_paths = WineUtilsProtonMixin.get_compatibility_tool_paths()
if not compat_paths:
return []
for compat_path in compat_paths:
try:
for proton_dir in compat_path.iterdir():
if not proton_dir.is_dir():
continue
dir_name = proton_dir.name
if dir_name.startswith("GE-Proton"):
continue
wine_bin = proton_dir / "files" / "bin" / "wine"
if not wine_bin.exists():
continue
compat_name = WineUtilsProtonMixin._parse_compat_tool_name(proton_dir)
if not compat_name:
continue
vdf_path = proton_dir / "compatibilitytool.vdf"
try:
with open(vdf_path, 'r', encoding='utf-8', errors='ignore') as f:
vdf_content = f.read()
if '"from_oslist" "linux"' in vdf_content:
continue
except Exception:
pass
if 'hotfix' in compat_name.lower():
continue
if compat_name in seen_names:
continue
seen_names.add(compat_name)
found_versions.append({
'name': dir_name,
'path': proton_dir,
'wine_bin': wine_bin,
'priority': 175,
'type': 'ThirdParty-Proton',
'steam_compat_name': compat_name,
})
logger.debug(f"Found third-party Proton: {dir_name} (compat name: {compat_name})")
except Exception as e:
logger.warning(f"Error scanning {compat_path}: {e}")
logger.info(f"Found {len(found_versions)} third-party Proton version(s)")
return found_versions
@staticmethod
def scan_ge_proton_versions() -> List[Dict[str, Any]]:
"""Scan for available GE-Proton versions in compatibilitytools.d directories."""
logger.info("Scanning for available GE-Proton versions...")
found_versions = []
compat_paths = WineUtilsProtonMixin.get_compatibility_tool_paths()
if not compat_paths:
logger.warning("No compatibility tool paths found")
return []
for compat_path in compat_paths:
logger.debug(f"Scanning compatibility tools: {compat_path}")
try:
for proton_dir in compat_path.iterdir():
if not proton_dir.is_dir():
continue
dir_name = proton_dir.name
if not dir_name.startswith("GE-Proton"):
continue
wine_bin = proton_dir / "files" / "bin" / "wine"
if not wine_bin.exists() or not wine_bin.is_file():
logger.debug(f"Skipping {dir_name} - no wine binary found")
continue
version_match = re.match(r'GE-Proton(\d+)-(\d+)', dir_name)
if version_match:
major_ver = int(version_match.group(1))
minor_ver = int(version_match.group(2))
priority = 200 + (major_ver * 10) + minor_ver
compat_name = WineUtilsProtonMixin._parse_compat_tool_name(proton_dir) or dir_name
found_versions.append({
'name': dir_name,
'path': proton_dir,
'wine_bin': wine_bin,
'priority': priority,
'major_version': major_ver,
'minor_version': minor_ver,
'type': 'GE-Proton',
'steam_compat_name': compat_name,
})
logger.debug(f"Found {dir_name} at {proton_dir} (priority: {priority})")
else:
logger.debug(f"Skipping {dir_name} - unknown GE-Proton version format")
except Exception as e:
logger.warning(f"Error scanning {compat_path}: {e}")
found_versions.sort(key=lambda x: x['priority'], reverse=True)
logger.info(f"Found {len(found_versions)} GE-Proton version(s)")
return found_versions
@staticmethod
def scan_valve_proton_versions() -> List[Dict[str, Any]]:
"""Scan for available Valve Proton versions with fallback priority."""
logger.info("Scanning for available Valve Proton versions...")
found_versions = []
steam_libs = WineUtilsProtonMixin.get_steam_library_paths()
if not steam_libs:
logger.warning("No Steam library paths found")
return []
preferred_versions = [
("Proton - Experimental", 150),
("Proton 10.0", 140),
("Proton 9.0", 130),
("Proton 9.0 (Beta)", 125)
]
for steam_path in steam_libs:
logger.debug(f"Scanning Steam library: {steam_path}")
for version_name, priority in preferred_versions:
proton_path = steam_path / version_name
wine_bin = proton_path / "files" / "bin" / "wine"
if wine_bin.exists() and wine_bin.is_file():
compat_name = WineUtilsProtonMixin.resolve_steam_compat_name(proton_path)
found_versions.append({
'name': version_name,
'path': proton_path,
'wine_bin': wine_bin,
'priority': priority,
'type': 'Valve-Proton',
'steam_compat_name': compat_name,
})
logger.debug(f"Found {version_name} at {proton_path}")
found_versions.sort(key=lambda x: x['priority'], reverse=True)
unique_versions = []
seen_names = set()
for version in found_versions:
if version['name'] not in seen_names:
unique_versions.append(version)
seen_names.add(version['name'])
logger.info(f"Found {len(unique_versions)} unique Valve Proton version(s)")
return unique_versions
@staticmethod
def scan_all_proton_versions() -> List[Dict[str, Any]]:
"""Scan for all available Proton versions (GE + third-party + Valve) with unified priority."""
logger.info("Scanning for all available Proton versions...")
all_versions = []
all_versions.extend(WineUtilsProtonMixin.scan_ge_proton_versions())
all_versions.extend(WineUtilsProtonMixin.scan_thirdparty_proton_versions())
all_versions.extend(WineUtilsProtonMixin.scan_valve_proton_versions())
all_versions.sort(key=lambda x: x['priority'], reverse=True)
unique_versions = []
seen_names = set()
for version in all_versions:
if version['name'] not in seen_names:
unique_versions.append(version)
seen_names.add(version['name'])
if unique_versions:
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")
return unique_versions
@staticmethod
def select_best_proton() -> Optional[Dict[str, Any]]:
"""Select the best available Proton (GE or Valve). Excludes third-party builds."""
available_versions = WineUtilsProtonMixin.scan_all_proton_versions()
if not available_versions:
logger.warning("No compatible Proton versions found")
return None
compatible_versions = [v for v in available_versions if v.get('type') in ('GE-Proton', 'Valve-Proton')]
if not compatible_versions:
logger.warning("No compatible Proton versions found (only third-party builds available)")
return None
best_version = compatible_versions[0]
logger.info(f"Selected best Proton version: {best_version['name']} ({best_version['type']})")
return best_version
@staticmethod
def select_best_valve_proton() -> Optional[Dict[str, Any]]:
"""Select the best available Valve Proton. Kept for backward compatibility."""
available_versions = WineUtilsProtonMixin.scan_valve_proton_versions()
if not available_versions:
logger.warning("No compatible Valve Proton versions found")
return None
best_version = available_versions[0]
logger.info(f"Selected Valve Proton version: {best_version['name']}")
return best_version
@staticmethod
def check_proton_requirements() -> Tuple[bool, str, Optional[Dict[str, Any]]]:
"""Check if a compatible Proton version is available for workflows."""
logger.info("Checking Proton requirements for workflow...")
best_proton = WineUtilsProtonMixin.select_best_proton()
if best_proton:
proton_type = best_proton.get('type', 'Unknown')
status_msg = f"[OK] Using {best_proton['name']} ({proton_type}) for this workflow"
logger.info(f"Proton requirements satisfied: {best_proton['name']} ({proton_type})")
return True, status_msg, best_proton
status_msg = "[FAIL] No compatible Proton version found (GE-Proton 10+, Proton 9+, 10, or Experimental required)"
logger.warning("Proton requirements not met - no compatible version found")
return False, status_msg, None

View File

@@ -0,0 +1,140 @@
"""
Wine wrapper script generation for winetricks.
Creates wrapper scripts similar to protontricks to properly set up
LD_LIBRARY_PATH and other environment variables before invoking wine/wineserver.
"""
import os
import stat
import shutil
import logging
from pathlib import Path
from typing import Optional
from jackify.shared.paths import get_jackify_data_dir
logger = logging.getLogger(__name__)
WINE_WRAPPER_TEMPLATE = '''#!/bin/bash
# Wine wrapper script generated by Jackify
# Ensures proper LD_LIBRARY_PATH setup when calling Proton wine binaries
PROTON_DIST_PATH="@@PROTON_DIST_PATH@@"
BINARY_NAME="@@BINARY_NAME@@"
# Set up LD_LIBRARY_PATH with Proton libraries first
PROTON_LIB_PATH="${PROTON_DIST_PATH}/lib64:${PROTON_DIST_PATH}/lib"
if [[ -n "$LD_LIBRARY_PATH" ]]; then
export LD_LIBRARY_PATH="${PROTON_LIB_PATH}:${LD_LIBRARY_PATH}"
else
export LD_LIBRARY_PATH="${PROTON_LIB_PATH}"
fi
# Enable fsync/esync by default if not already set
if [[ -z "$WINEFSYNC" && -z "$PROTON_NO_FSYNC" ]]; then
export WINEFSYNC=1
fi
if [[ -z "$WINEESYNC" && -z "$PROTON_NO_ESYNC" ]]; then
export WINEESYNC=1
fi
# Execute the actual Proton binary
exec "${PROTON_DIST_PATH}/bin/${BINARY_NAME}" "$@"
'''
class WineWrapperManager:
"""Manages creation of wine/wineserver wrapper scripts for winetricks."""
def __init__(self):
self.logger = logging.getLogger(__name__)
self._wrapper_dir: Optional[Path] = None
def get_wrapper_dir(self, proton_path: str) -> Path:
"""Get or create the wrapper directory for a specific Proton version."""
proton_name = Path(proton_path).name.replace(" ", "_")
cache_dir = get_jackify_data_dir() / "wine_wrappers" / proton_name
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir
def create_wrappers(self, proton_dist_path: str) -> Optional[Path]:
"""
Create wine and wineserver wrapper scripts for the given Proton dist path.
Args:
proton_dist_path: Path to Proton's dist directory (containing bin/, lib/, lib64/)
Returns:
Path to the wrapper directory, or None if creation failed
"""
try:
proton_dist = Path(proton_dist_path)
if not proton_dist.exists():
self.logger.error(f"Proton dist path does not exist: {proton_dist_path}")
return None
# Verify required binaries exist
wine_bin = proton_dist / "bin" / "wine"
wineserver_bin = proton_dist / "bin" / "wineserver"
if not wine_bin.exists():
self.logger.error(f"Wine binary not found: {wine_bin}")
return None
if not wineserver_bin.exists():
self.logger.error(f"Wineserver binary not found: {wineserver_bin}")
return None
# Get wrapper directory based on Proton install path (parent of dist)
proton_install_path = proton_dist.parent
wrapper_dir = self.get_wrapper_dir(str(proton_install_path))
# Clean and recreate to ensure fresh scripts
if wrapper_dir.exists():
shutil.rmtree(str(wrapper_dir))
wrapper_dir.mkdir(parents=True, exist_ok=True)
# Create wrapper for each binary in Proton's bin directory
binaries_to_wrap = ["wine", "wine64", "wineserver", "wineboot", "winecfg"]
created_wrappers = []
for binary_name in binaries_to_wrap:
binary_path = proton_dist / "bin" / binary_name
if not binary_path.exists():
continue
wrapper_path = wrapper_dir / binary_name
wrapper_content = WINE_WRAPPER_TEMPLATE.replace(
"@@PROTON_DIST_PATH@@", str(proton_dist)
).replace(
"@@BINARY_NAME@@", binary_name
)
wrapper_path.write_text(wrapper_content)
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC)
created_wrappers.append(binary_name)
self.logger.info(f"Created wine wrappers in {wrapper_dir}: {', '.join(created_wrappers)}")
self._wrapper_dir = wrapper_dir
return wrapper_dir
except Exception as e:
self.logger.error(f"Failed to create wine wrappers: {e}", exc_info=True)
return None
def get_wine_wrapper_path(self, proton_dist_path: str) -> Optional[str]:
"""Get path to the wine wrapper script."""
wrapper_dir = self.create_wrappers(proton_dist_path)
if wrapper_dir:
wine_wrapper = wrapper_dir / "wine"
if wine_wrapper.exists():
return str(wine_wrapper)
return None
def get_wineserver_wrapper_path(self, proton_dist_path: str) -> Optional[str]:
"""Get path to the wineserver wrapper script."""
wrapper_dir = self.create_wrappers(proton_dist_path)
if wrapper_dir:
wineserver_wrapper = wrapper_dir / "wineserver"
if wineserver_wrapper.exists():
return str(wineserver_wrapper)
return None

View File

@@ -0,0 +1,84 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Winetricks discovery mixin: bundled path and tool availability.
Extracted from winetricks_handler for file-size and domain separation.
"""
import os
import subprocess
import logging
from pathlib import Path
from typing import Optional
class WinetricksDiscoveryMixin:
"""Mixin providing winetricks path discovery and availability checks."""
def _get_bundled_winetricks_path(self) -> Optional[str]:
"""Get the path to the bundled winetricks script (AppImage and dev)."""
possible_paths = []
if os.environ.get('APPDIR'):
appdir_path = os.path.join(os.environ['APPDIR'], 'opt', 'jackify', 'tools', 'winetricks')
possible_paths.append(appdir_path)
module_dir = Path(__file__).parent.parent.parent
dev_path = module_dir / 'tools' / 'winetricks'
possible_paths.append(str(dev_path))
for path in possible_paths:
if os.path.exists(path) and os.access(path, os.X_OK):
self.logger.debug(f"Found bundled winetricks at: {path}")
return str(path)
self.logger.error(f"Bundled winetricks not found. Tried paths: {possible_paths}")
return None
def _get_bundled_tool(self, tool_name: str, fallback_to_system: bool = True) -> Optional[str]:
"""Get path to a bundled tool (e.g. cabextract, wget). Fall back to system PATH if requested."""
possible_paths = []
if os.environ.get('APPDIR'):
appdir_path = os.path.join(os.environ['APPDIR'], 'opt', 'jackify', 'tools', tool_name)
possible_paths.append(appdir_path)
module_dir = Path(__file__).parent.parent.parent
dev_path = module_dir / 'tools' / tool_name
possible_paths.append(str(dev_path))
for path in possible_paths:
if os.path.exists(path) and os.access(path, os.X_OK):
self.logger.debug(f"Found bundled {tool_name} at: {path}")
return str(path)
if fallback_to_system:
try:
import shutil
system_tool = shutil.which(tool_name)
if system_tool:
self.logger.debug(f"Using system {tool_name}: {system_tool}")
return system_tool
except Exception:
pass
self.logger.debug(f"Bundled {tool_name} not found in tools directory")
return None
def _get_bundled_cabextract(self) -> Optional[str]:
"""Get the path to the bundled cabextract binary. Backward compatibility."""
return self._get_bundled_tool('cabextract', fallback_to_system=True)
def is_available(self) -> bool:
"""Check if winetricks is available and ready to use."""
if not self.winetricks_path:
self.logger.error("Bundled winetricks not found")
return False
try:
env = os.environ.copy()
result = subprocess.run(
[self.winetricks_path, '--version'],
capture_output=True,
text=True,
env=env,
timeout=10
)
if result.returncode == 0:
self.logger.debug(f"Winetricks version: {result.stdout.strip()}")
return True
self.logger.error(f"Winetricks --version failed: {result.stderr}")
return False
except Exception as e:
self.logger.error(f"Error testing winetricks: {e}")
return False

View File

@@ -0,0 +1,301 @@
"""
Winetricks environment and dependency setup for install_wine_components.
Builds env dict, checks downloaders/deps, resolves components list.
"""
import os
import sys
import subprocess
import logging
from typing import Optional, List, Callable, Tuple
logger = logging.getLogger(__name__)
def _get_clean_winetricks_base_env() -> dict:
"""
Base environment for winetricks subprocess with no AppImage/bundle vars.
Wine and wineserver must not see _MEIPASS, bundle PATH/LD_LIBRARY_PATH or
connection reset / regsvr32 failures can occur when running from AppImage.
"""
preserve = [
"HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LANGUAGE",
"DISPLAY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", "XAUTHORITY",
"XDG_SESSION_TYPE", "DBUS_SESSION_BUS_ADDRESS", "XDG_DATA_DIRS", "XDG_CONFIG_DIRS",
"XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "QT_QPA_PLATFORM", "GDK_BACKEND",
]
env = {}
for var in preserve:
if var in os.environ:
env[var] = os.environ[var]
if "HOME" not in env and "HOME" in os.environ:
env["HOME"] = os.environ["HOME"]
path = os.environ.get("PATH", "")
if getattr(sys, "_MEIPASS", None):
path = os.pathsep.join(p for p in path.split(os.pathsep) if not p.startswith(sys._MEIPASS))
env["PATH"] = path or "/usr/bin:/bin"
return env
class WinetricksEnvMixin:
"""Mixin providing env build and dependency check for WinetricksHandler.install_wine_components."""
def _build_winetricks_env(
self,
wineprefix: str,
status_callback: Optional[Callable[[str], None]],
specific_components: Optional[List[str]],
) -> Tuple[Optional[dict], Optional[List[str]]]:
"""
Build environment and resolve components for winetricks. Returns (env, components_to_install) or (None, None).
Uses a clean base env (no AppImage/bundle vars) so wine/wineserver see only Proton and system.
"""
env = _get_clean_winetricks_base_env()
env['WINEDEBUG'] = '-all'
env['WINEPREFIX'] = wineprefix
env['WINETRICKS_GUI'] = 'none'
if 'DISPLAY' in env:
env['WINEDLLOVERRIDES'] = 'winemenubuilder.exe=d'
else:
env['DISPLAY'] = env.get('DISPLAY', '')
try:
from ..handlers.config_handler import ConfigHandler
from ..handlers.wine_utils import WineUtils
config = ConfigHandler()
user_proton_path = config.get_proton_path()
wine_binary = None
if user_proton_path and user_proton_path != 'auto':
if os.path.exists(user_proton_path):
resolved_proton_path = os.path.realpath(user_proton_path)
valve_proton_wine = os.path.join(resolved_proton_path, 'dist', 'bin', 'wine')
ge_proton_wine = os.path.join(resolved_proton_path, 'files', 'bin', 'wine')
if os.path.exists(valve_proton_wine):
wine_binary = valve_proton_wine
self.logger.info(f"Using user-selected Proton: {user_proton_path}")
elif os.path.exists(ge_proton_wine):
wine_binary = ge_proton_wine
self.logger.info(f"Using user-selected GE-Proton: {user_proton_path}")
else:
self.logger.warning(f"User-selected Proton path invalid: {user_proton_path}")
else:
self.logger.warning(f"User-selected Proton no longer exists: {user_proton_path}")
if not wine_binary:
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("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:
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 (None, None)
if not wine_binary:
self.logger.error("Cannot run winetricks: No compatible Proton version found")
self.logger.error("Please ensure you have Proton 9+ or GE-Proton installed through Steam")
return (None, None)
if not (os.path.exists(wine_binary) and os.access(wine_binary, os.X_OK)):
self.logger.error(f"Cannot run winetricks: Wine binary not found or not executable: {wine_binary}")
return (None, None)
proton_dist_path = os.path.dirname(os.path.dirname(wine_binary))
self.logger.debug(f"Proton dist path: {proton_dist_path}")
# Create wine wrapper scripts (like protontricks does) to ensure proper
# LD_LIBRARY_PATH setup when winetricks spawns wine subprocesses
from .wine_wrapper import WineWrapperManager
wrapper_manager = WineWrapperManager()
wrapper_dir = wrapper_manager.create_wrappers(proton_dist_path)
if wrapper_dir:
wine_wrapper = wrapper_dir / "wine"
wineserver_wrapper = wrapper_dir / "wineserver"
env['WINE'] = str(wine_wrapper)
env['WINELOADER'] = str(wine_wrapper)
env['WINESERVER'] = str(wineserver_wrapper)
# Put wrapper dir first in PATH so winetricks finds our wrappers
env['PATH'] = f"{wrapper_dir}:{proton_dist_path}/bin:{env.get('PATH', '')}"
self.logger.info(f"Using wine wrappers for winetricks: {wrapper_dir}")
self.logger.debug(f"WINE={wine_wrapper}, WINESERVER={wineserver_wrapper}")
else:
# Fallback to direct binary paths if wrapper creation fails
self.logger.warning("Wine wrapper creation failed, using direct binary paths")
env['WINE'] = str(wine_binary)
env['WINELOADER'] = str(wine_binary)
wineserver_bin = os.path.join(proton_dist_path, 'bin', 'wineserver')
if os.path.exists(wineserver_bin) and os.access(wineserver_bin, os.X_OK):
env['WINESERVER'] = wineserver_bin
env['PATH'] = f"{proton_dist_path}/bin:{env.get('PATH', '')}"
env['WINEDLLPATH'] = f"{proton_dist_path}/lib64/wine:{proton_dist_path}/lib/wine"
# LD_LIBRARY_PATH is now set by wrapper scripts, but set it here too for completeness
ld_prepend = f"{proton_dist_path}/lib64:{proton_dist_path}/lib"
env['LD_LIBRARY_PATH'] = f"{ld_prepend}:{env.get('LD_LIBRARY_PATH', '')}" if env.get('LD_LIBRARY_PATH') else ld_prepend
self.logger.debug(f"Set LD_LIBRARY_PATH for Proton (prepend): {ld_prepend[:80]}...")
dll_overrides = {
"beclient": "b,n", "beclient_x64": "b,n", "dxgi": "n", "d3d9": "n",
"d3d10core": "n", "d3d11": "n", "d3d12": "n", "d3d12core": "n",
"nvapi": "n", "nvapi64": "n", "nvofapi64": "n", "nvcuda": "b"
}
existing_overrides = env.get('WINEDLLOVERRIDES', '')
if existing_overrides:
for override in existing_overrides.split(';'):
if '=' in override:
name, value = override.split('=', 1)
dll_overrides[name] = value
env['WINEDLLOVERRIDES'] = ';'.join(f"{name}={setting}" for name, setting in dll_overrides.items())
env['WINE_LARGE_ADDRESS_AWARE'] = '1'
env['DXVK_ENABLE_NVAPI'] = '1'
self.logger.debug(f"Set protontricks environment: WINEDLLPATH={env['WINEDLLPATH']}")
except Exception as e:
self.logger.error(f"Cannot run winetricks: Failed to get Proton wine binary: {e}")
return (None, None)
has_downloader = False
for tool in ['aria2c', 'curl', 'wget']:
try:
result = subprocess.run(['which', tool], capture_output=True, timeout=2, env=os.environ.copy())
if result.returncode == 0:
has_downloader = True
self.logger.info(f"System has {tool} available - winetricks will auto-select best option")
break
except Exception:
pass
if not has_downloader:
self._handle_missing_downloader_error()
return (None, None)
tools_dir = None
bundled_tools = []
for tool_name in ['cabextract', 'unzip', '7z', 'xz', 'sha256sum']:
bundled_tool = self._get_bundled_tool(tool_name, fallback_to_system=False)
if bundled_tool:
bundled_tools.append(tool_name)
if tools_dir is None:
tools_dir = os.path.dirname(bundled_tool)
if tools_dir:
env['PATH'] = f"{env.get('PATH', '')}:{tools_dir}"
bundling_msg = f"Using bundled tools directory (after system PATH): {tools_dir}"
self.logger.info(bundling_msg)
if status_callback:
status_callback(bundling_msg)
tools_msg = f"Bundled tools available: {', '.join(bundled_tools)}"
self.logger.info(tools_msg)
if status_callback:
status_callback(tools_msg)
else:
self.logger.debug("No bundled tools found, relying on system PATH")
deps_check_msg = "=== Checking winetricks dependencies ==="
self.logger.info(deps_check_msg)
if status_callback:
status_callback(deps_check_msg)
missing_deps = []
bundled_tools_list = ['aria2c', 'wget', 'unzip', '7z', 'xz', 'sha256sum', 'cabextract']
dependency_checks = {
'wget': 'wget', 'curl': 'curl', 'aria2c': 'aria2c', 'unzip': 'unzip',
'7z': ['7z', '7za', '7zr'], 'xz': 'xz',
'sha256sum': ['sha256sum', 'sha256', 'shasum'], 'perl': 'perl'
}
for dep_name, commands in dependency_checks.items():
found = False
if isinstance(commands, str):
commands = [commands]
if dep_name in bundled_tools_list:
for cmd in commands:
bundled_tool = self._get_bundled_tool(cmd, fallback_to_system=False)
if bundled_tool:
dep_msg = f" {dep_name}: {bundled_tool} (bundled)"
self.logger.info(dep_msg)
if status_callback:
status_callback(dep_msg)
found = True
break
if not found:
for cmd in commands:
try:
result = subprocess.run(['which', cmd], capture_output=True, timeout=2)
if result.returncode == 0:
cmd_path = result.stdout.decode().strip()
dep_msg = f" {dep_name}: {cmd_path} (system)"
self.logger.info(dep_msg)
if status_callback:
status_callback(dep_msg)
found = True
break
except Exception:
pass
if not found:
missing_deps.append(dep_name)
if dep_name in bundled_tools_list:
self.logger.warning(f" {dep_name}: NOT FOUND (neither bundled nor system)")
else:
self.logger.warning(f" {dep_name}: NOT FOUND (system only - not bundled)")
if missing_deps:
download_deps = [d for d in missing_deps if d in ['wget', 'curl', 'aria2c']]
verbose = getattr(self, 'verbose', False)
if verbose:
critical_deps = [d for d in missing_deps if d not in ['aria2c']]
if critical_deps:
self.logger.warning(f"Missing critical winetricks dependencies: {', '.join(critical_deps)}")
self.logger.warning("Winetricks may fail if these are required for component installation")
optional_deps = [d for d in missing_deps if d in ['aria2c']]
if optional_deps:
self.logger.info(f"Optional dependencies not found (will use alternatives): {', '.join(optional_deps)}")
all_downloaders = {'wget', 'curl', 'aria2c'}
if set(download_deps) == all_downloaders:
self.logger.error("=" * 80)
self.logger.error("CRITICAL: No download tools found (wget, curl, or aria2c)")
self.logger.error("Winetricks requires at least ONE download tool to install components")
self.logger.error("")
self.logger.error("SOLUTION: Install one of the following:")
self.logger.error(" - aria2c (preferred): sudo apt install aria2 # or equivalent for your distro")
self.logger.error(" - curl: sudo apt install curl # or equivalent for your distro")
self.logger.error(" - wget: sudo apt install wget # or equivalent for your distro")
self.logger.error("=" * 80)
elif getattr(self, 'verbose', False):
self.logger.warning("Critical dependencies: wget/curl (download), unzip/7z (extract)")
elif getattr(self, 'verbose', False):
self.logger.info("All winetricks dependencies found")
if getattr(self, 'verbose', False):
self.logger.info("========================================")
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)
if specific_components is not None:
all_components = specific_components
self.logger.info(f"Installing specific components: {all_components}")
else:
all_components = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"]
self.logger.info(f"Installing default components: {all_components}")
if not all_components:
self.logger.info("No Wine components to install.")
if status_callback:
status_callback("No Wine components to install")
return (env, [])
components_to_install = self._reorder_components_for_installation(all_components)
self.logger.info(f"WINEPREFIX: {wineprefix}, Ordered Components: {components_to_install}")
if status_callback:
status_callback(f"Installing Wine components: {', '.join(components_to_install)}")
return (env, components_to_install)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,262 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Winetricks installation mixin: environment, run winetricks, protontricks fallback.
Extracted from winetricks_handler for file-size and domain separation.
"""
import os
import subprocess
import logging
from pathlib import Path
from typing import Optional, List, Callable
logger = logging.getLogger(__name__)
class WinetricksInstallationMixin:
"""Mixin providing winetricks environment setup and component installation strategies."""
def _reorder_components_for_installation(self, components: list) -> list:
"""Reorder components for proper installation sequence. Currently returns original order."""
return components
def _prepare_winetricks_environment(self, wineprefix: str) -> Optional[dict]:
"""Prepare environment for winetricks (Proton detection, DLL overrides, cache). Returns env dict or None."""
try:
env = os.environ.copy()
env['WINEDEBUG'] = '-all'
env['WINEPREFIX'] = wineprefix
env['WINETRICKS_GUI'] = 'none'
from ..handlers.config_handler import ConfigHandler
from ..handlers.wine_utils import WineUtils
config = ConfigHandler()
user_proton_path = config.get_proton_path()
wine_binary = None
if user_proton_path and user_proton_path != 'auto':
if os.path.exists(user_proton_path):
resolved_proton_path = os.path.realpath(user_proton_path)
valve_proton_wine = os.path.join(resolved_proton_path, 'dist', 'bin', 'wine')
ge_proton_wine = os.path.join(resolved_proton_path, 'files', 'bin', 'wine')
if os.path.exists(valve_proton_wine):
wine_binary = valve_proton_wine
elif os.path.exists(ge_proton_wine):
wine_binary = ge_proton_wine
if not wine_binary:
if not user_proton_path or user_proton_path == 'auto':
self.logger.info("Auto-detecting Proton (user selected 'auto' or path not set)")
best_proton = WineUtils.select_best_proton()
if best_proton:
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
else:
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("Cannot prepare winetricks environment: No compatible Proton found")
return None
env['WINE'] = str(wine_binary)
proton_dist_path = os.path.dirname(os.path.dirname(wine_binary))
env['WINEDLLPATH'] = f"{proton_dist_path}/lib64/wine:{proton_dist_path}/lib/wine"
env['PATH'] = f"{proton_dist_path}/bin:{env.get('PATH', '')}"
dll_overrides = {
"beclient": "b,n", "beclient_x64": "b,n", "dxgi": "n", "d3d9": "n",
"d3d10core": "n", "d3d11": "n", "d3d12": "n", "d3d12core": "n",
"nvapi": "n", "nvapi64": "n", "nvofapi64": "n", "nvcuda": "b"
}
env['WINEDLLOVERRIDES'] = ';'.join(f"{name}={setting}" for name, setting in dll_overrides.items())
env['WINE_LARGE_ADDRESS_AWARE'] = '1'
env['DXVK_ENABLE_NVAPI'] = '1'
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)
return env
except Exception as e:
self.logger.error(f"Failed to prepare winetricks environment: {e}")
return None
def _install_components_with_winetricks(self, components: list, wineprefix: str, env: dict) -> bool:
"""Install components using winetricks with the prepared environment."""
max_attempts = 3
for attempt in range(1, max_attempts + 1):
if attempt > 1:
self.logger.warning(f"Retrying winetricks installation (attempt {attempt}/{max_attempts})")
self._cleanup_wine_processes()
try:
cmd = [self.winetricks_path, '--unattended'] + components
self.logger.debug(f"Running winetricks: {' '.join(cmd)}")
result = subprocess.run(
cmd,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=600
)
if result.returncode == 0:
self.logger.info("Winetricks components installation command completed.")
if self._verify_components_installed(wineprefix, components, env):
self.logger.info("Component verification successful - all components installed correctly.")
wine_binary = env.get('WINE', '')
self._set_windows_10_mode(env.get('WINEPREFIX', ''), wine_binary)
return True
self.logger.error(f"Component verification failed (attempt {attempt})")
else:
self.logger.error(f"Winetricks failed (attempt {attempt}): {result.stderr.strip()}")
except Exception as e:
self.logger.error(f"Error during winetricks run (attempt {attempt}): {e}")
self.logger.error(f"Failed to install components with winetricks after {max_attempts} attempts")
return False
def _set_windows_10_mode(self, wineprefix: str, wine_binary: str) -> None:
"""Set Windows 10 mode for the prefix after component installation."""
try:
env = os.environ.copy()
env['WINEPREFIX'] = wineprefix
env['WINE'] = wine_binary
self.logger.info("Setting Windows 10 mode after component installation (matching legacy script)")
result = subprocess.run(
[self.winetricks_path, '-q', 'win10'],
env=env,
capture_output=True,
text=True,
timeout=300
)
if result.returncode == 0:
self.logger.info("Windows 10 mode set successfully")
else:
self.logger.warning(f"Could not set Windows 10 mode: {result.stderr}")
except Exception as e:
self.logger.warning(f"Error setting Windows 10 mode: {e}")
def _set_windows_10_mode_after_install(self, wineprefix: str, install_env: dict) -> None:
"""Set Windows 10 mode for the prefix after component installation."""
try:
self._set_windows_10_mode(wineprefix, install_env.get('WINE', ''))
except Exception as e:
self.logger.warning(f"Error setting Windows 10 mode: {e}")
def _install_components_separately(self, components: list, wineprefix: str, wine_binary: str, base_env: dict) -> bool:
"""Install components one at a time for maximum compatibility."""
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}")
env = base_env.copy()
env['WINEPREFIX'] = wineprefix
env['WINE'] = wine_binary
max_attempts = 3
component_success = False
for attempt in range(1, max_attempts + 1):
if attempt > 1:
self.logger.warning(f"Retrying {component} installation (attempt {attempt}/{max_attempts})")
self._cleanup_wine_processes()
try:
cmd = [self.winetricks_path, '--unattended', component]
self.logger.debug(f"Running: {' '.join(cmd)}")
result = subprocess.run(
cmd,
env=env,
capture_output=True,
text=True,
timeout=600
)
if result.returncode == 0:
self.logger.info(f"{component} installed successfully")
component_success = True
break
self.logger.error(f"{component} failed (attempt {attempt}): {result.stderr.strip()}")
self.logger.debug(f"Full stdout for {component}: {result.stdout.strip()}")
except Exception as e:
self.logger.error(f"Error installing {component} (attempt {attempt}): {e}")
if not component_success:
self.logger.error(f"Failed to install {component} after {max_attempts} attempts")
return False
self.logger.info("All components installed successfully using separate sessions")
self._set_windows_10_mode(wineprefix, env.get('WINE', ''))
return True
def _is_flatpak_steam_prefix(self, wineprefix: str) -> bool:
"""True if wineprefix is under Flatpak Steam (.var/app/com.valvesoftware.Steam)."""
if not wineprefix:
return False
path_str = os.fspath(wineprefix)
return ".var" in path_str and "app" in path_str and "com.valvesoftware.Steam" in path_str
def _extract_appid_from_wineprefix(self, wineprefix: str) -> Optional[str]:
"""Extract AppID from wineprefix path (compatdata/AppID)."""
try:
if 'compatdata' in wineprefix:
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():
return potential_appid
self.logger.error(f"Could not extract AppID from wineprefix path: {wineprefix}")
return None
except Exception as e:
self.logger.error(f"Error extracting AppID from wineprefix: {e}")
return None
def _get_wine_binary_for_prefix(self, wineprefix: str) -> str:
"""Get the wine binary path for a given prefix (user Proton or auto-detect)."""
try:
from ..handlers.config_handler import ConfigHandler
from ..handlers.wine_utils import WineUtils
config = ConfigHandler()
user_proton_path = config.get_proton_path()
wine_binary = None
if user_proton_path and user_proton_path != 'auto':
if os.path.exists(user_proton_path):
resolved_proton_path = os.path.realpath(user_proton_path)
valve_proton_wine = os.path.join(resolved_proton_path, 'dist', 'bin', 'wine')
ge_proton_wine = os.path.join(resolved_proton_path, 'files', 'bin', 'wine')
if os.path.exists(valve_proton_wine):
wine_binary = valve_proton_wine
elif os.path.exists(ge_proton_wine):
wine_binary = ge_proton_wine
if not wine_binary:
if not user_proton_path or user_proton_path == 'auto':
self.logger.info("Auto-detecting Proton (user selected 'auto' or path not set)")
best_proton = WineUtils.select_best_proton()
if best_proton:
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
else:
self.logger.error(f"Configured Proton not found: {user_proton_path}")
return ""
return wine_binary if wine_binary else ""
except Exception as e:
self.logger.error(f"Error getting wine binary for prefix: {e}")
return ""
def _install_components_protontricks_only(self, components: list, wineprefix: str, game_var: str,
status_callback: Optional[Callable[[str], None]] = None,
appid: Optional[str] = None) -> bool:
"""Install all components using system protontricks only. appid can be passed in or extracted from wineprefix."""
try:
self.logger.info(f"Installing all components with system protontricks: {components}")
from ..handlers.protontricks_handler import ProtontricksHandler
steamdeck = os.path.exists('/home/deck')
protontricks_handler = ProtontricksHandler(steamdeck, logger=self.logger)
resolved_appid = appid or self._extract_appid_from_wineprefix(wineprefix)
if not resolved_appid:
self.logger.error("Could not extract AppID from wineprefix for protontricks installation")
return False
self.logger.info(f"Using AppID {resolved_appid} for protontricks installation")
if not protontricks_handler.detect_protontricks():
self.logger.error("Protontricks not available for component installation")
return False
components_list = ', '.join(components)
if status_callback:
status_callback(f"Installing Wine components via protontricks: {components_list}")
success = protontricks_handler.install_wine_components(resolved_appid, game_var, components)
if success:
self.logger.info("All components installed successfully with protontricks")
wine_binary = self._get_wine_binary_for_prefix(wineprefix)
self._set_windows_10_mode(wineprefix, wine_binary)
return True
self.logger.error("Component installation failed with protontricks")
return False
except Exception as e:
self.logger.error(f"Error installing components with protontricks: {e}", exc_info=True)
return False

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Winetricks verification mixin: component install verification.
Extracted from winetricks_handler for file-size and domain separation.
"""
import os
import logging
from typing import List
class WinetricksVerificationMixin:
"""Mixin providing verification of installed Wine components."""
def _verify_components_installed(self, wineprefix: str, components: List[str], env: dict) -> bool:
"""Verify that every requested component was installed (winetricks.log)."""
try:
self.logger.info("Verifying installed components...")
winetricks_log = os.path.join(wineprefix, 'winetricks.log')
log_content = ""
if os.path.exists(winetricks_log):
try:
with open(winetricks_log, 'r', encoding='utf-8', errors='ignore') as f:
log_content = f.read().lower()
except Exception as e:
self.logger.error(f"Failed to read winetricks.log: {e}")
return False
self.logger.debug(f"winetricks.log length: {len(log_content)} bytes")
missing = []
for component in components:
base_component = component.split('=')[0].lower()
if base_component in log_content or component.lower() in log_content:
continue
missing.append(component)
if missing:
self.logger.error(f"Components not verified installed: {missing}")
return False
self.logger.info("Verification passed - all components confirmed")
return True
except Exception as e:
self.logger.error(f"Error verifying components: {e}", exc_info=True)
return False

View File

@@ -0,0 +1,500 @@
"""Prefix creation methods for AutomatedPrefixService (Mixin)."""
from pathlib import Path
from typing import Optional
import logging
import os
import time
import subprocess
logger = logging.getLogger(__name__)
def debug_print(message):
"""Log debug message only if debug mode is enabled"""
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
if config_handler.get('debug_mode', False):
logger.debug(message)
class PrefixCreationMixin:
"""Mixin providing prefix creation methods for AutomatedPrefixService."""
def detect_actual_prefix_appid(self, initial_appid: int, shortcut_name: str) -> Optional[int]:
"""
After Steam restart, detect the actual prefix AppID that was created.
Uses direct VDF file reading to find the actual AppID.
Args:
initial_appid: The initial (negative) AppID from shortcuts.vdf
shortcut_name: Name of the shortcut for logging
Returns:
The actual (positive) AppID of the created prefix, or None if not found
"""
try:
logger.info(f"Using VDF to detect actual AppID for shortcut: {shortcut_name}")
# Wait up to 30 seconds for Steam to process the shortcut
for i in range(30):
try:
from ..handlers.shortcut_handler import ShortcutHandler
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
shortcuts_path = path_handler._find_shortcuts_vdf()
if shortcuts_path:
from ..handlers.vdf_handler import VDFHandler
shortcuts_data = VDFHandler.load(shortcuts_path, binary=True)
if shortcuts_data and 'shortcuts' in shortcuts_data:
for idx, shortcut in shortcuts_data['shortcuts'].items():
app_name = shortcut.get('AppName', shortcut.get('appname', '')).strip()
if app_name.lower() == shortcut_name.lower():
appid = shortcut.get('appid')
if appid:
actual_appid = int(appid) & 0xFFFFFFFF
logger.info(f"Found shortcut '{app_name}' in shortcuts.vdf")
logger.info(f" Initial AppID (signed): {initial_appid}")
logger.info(f" Actual AppID (unsigned): {actual_appid}")
return actual_appid
logger.debug(f"Shortcut '{shortcut_name}' not found in VDF yet (attempt {i+1}/30)")
time.sleep(1)
except Exception as e:
logger.warning(f"Error reading shortcuts.vdf on attempt {i+1}: {e}")
time.sleep(1)
logger.error(f"Shortcut '{shortcut_name}' not found in shortcuts.vdf after 30 seconds")
return None
except Exception as e:
logger.error(f"Error detecting actual prefix AppID: {e}")
return None
def launch_shortcut_to_trigger_prefix(self, initial_appid: int) -> bool:
"""
Launch the shortcut using rungameid to trigger prefix creation.
This follows the same pattern as the working test script.
Args:
initial_appid: The initial (negative) AppID from shortcuts.vdf
Returns:
True if successful, False otherwise
"""
try:
# Convert signed AppID to unsigned AppID (same as STL's generateSteamShortID)
unsigned_appid = self.generate_steam_short_id(initial_appid)
# Calculate rungameid using the unsigned AppID
rungameid = (unsigned_appid << 32) | 0x02000000
logger.info(f"Launching shortcut with rungameid: {rungameid}")
debug_print(f"[DEBUG] Launching shortcut with rungameid: {rungameid}")
debug_print(f"[DEBUG] Initial signed AppID: {initial_appid}")
debug_print(f"[DEBUG] Unsigned AppID: {unsigned_appid}")
# Launch using rungameid
cmd = ['steam', f'steam://rungameid/{rungameid}']
debug_print(f"[DEBUG] About to run launch command: {' '.join(cmd)}")
# Use subprocess.Popen to launch asynchronously (steam command returns immediately)
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
# Wait a moment for the process to start
time.sleep(1)
# Check if the process is still running (steam command should exit quickly)
try:
return_code = process.poll()
if return_code is None:
# Process is still running, wait a bit more
time.sleep(2)
return_code = process.poll()
debug_print(f"[DEBUG] Steam launch process return code: {return_code}")
# Get any output
stdout, stderr = process.communicate(timeout=1)
if stdout:
debug_print(f"[DEBUG] Steam launch stdout: {stdout}")
if stderr:
debug_print(f"[DEBUG] Steam launch stderr: {stderr}")
except subprocess.TimeoutExpired:
debug_print("[DEBUG] Steam launch process timed out, but that's OK")
process.kill()
logger.info(f"Launch command executed: {' '.join(cmd)}")
# Give it a moment for the shortcut to actually start
time.sleep(5)
return True
except subprocess.TimeoutExpired:
logger.error("Launch command timed out")
debug_print("[DEBUG] Launch command timed out")
return False
except Exception as e:
logger.error(f"Error launching shortcut: {e}")
debug_print(f"[DEBUG] Error launching shortcut: {e}")
return False
def create_prefix_directly(self, appid: int, batch_file_path: str) -> Optional[Path]:
"""
Create prefix directly using Proton wrapper.
Args:
appid: The AppID from the shortcut
batch_file_path: Path to the temporary batch file
Returns:
Path to the created prefix, or None if failed
"""
proton_path = self.find_proton_experimental()
if not proton_path:
return None
# Steam uses negative AppIDs for non-Steam shortcuts, but we need the positive value for the prefix path
positive_appid = abs(appid)
logger.info(f"Using positive AppID {positive_appid} for prefix creation (original: {appid})")
# Create the prefix directory structure
prefix_path = self._get_compatdata_path_for_appid(positive_appid)
if not prefix_path:
logger.error(f"Could not determine compatdata path for AppID {positive_appid}")
return None
# Create the prefix directory structure
prefix_path.mkdir(parents=True, exist_ok=True)
pfx_dir = prefix_path / "pfx"
pfx_dir.mkdir(exist_ok=True)
# Set up environment
env = os.environ.copy()
env['STEAM_COMPAT_DATA_PATH'] = str(prefix_path)
env['STEAM_COMPAT_APP_ID'] = str(positive_appid) # Use positive AppID for environment
# Determine correct Steam root based on installation type
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
steam_library = path_handler.find_steam_library()
if steam_library and steam_library.name == "common":
# Extract Steam root from library path: .../Steam/steamapps/common -> .../Steam
steam_root = steam_library.parent.parent
env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(steam_root)
else:
# Fallback to legacy path if detection fails
env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(Path.home() / ".local/share/Steam")
# Build the command
cmd = [
str(proton_path / "proton"),
"run",
batch_file_path
]
logger.info(f"Creating prefix with command: {' '.join(cmd)}")
logger.info(f"Prefix path: {prefix_path}")
logger.info(f"Using AppID: {positive_appid} (original: {appid})")
try:
# Run the command with a timeout
result = subprocess.run(
cmd,
env=env,
capture_output=True,
text=True,
timeout=30
)
# Check if prefix was created
time.sleep(2) # Give it a moment to settle
prefix_created = prefix_path.exists()
pfx_exists = (prefix_path / "pfx").exists()
logger.info(f"Return code: {result.returncode}")
logger.info(f"Prefix created: {prefix_created}")
logger.info(f"pfx directory exists: {pfx_exists}")
if result.stderr:
logger.debug(f"stderr: {result.stderr.strip()}")
success = prefix_created and pfx_exists
if success:
logger.info(f"Prefix created successfully at: {prefix_path}")
return prefix_path
else:
logger.error("Failed to create prefix")
return None
except subprocess.TimeoutExpired:
logger.warning("Command timed out, but this might be normal")
# Check if prefix was created despite timeout
prefix_created = prefix_path.exists()
pfx_exists = (prefix_path / "pfx").exists()
if prefix_created and pfx_exists:
logger.info(f"Prefix created successfully despite timeout at: {prefix_path}")
return prefix_path
else:
logger.error("No prefix created")
return None
except Exception as e:
logger.error(f"Error creating prefix: {e}")
return None
def _get_compatdata_path_for_appid(self, appid: int) -> Optional[Path]:
"""
Get the compatdata path for a given AppID.
First tries to find existing compatdata, then constructs path from libraryfolders.vdf
for creating new prefixes.
Args:
appid: The AppID to get the path for
Returns:
Path to the compatdata directory, or None if not found
"""
from ..handlers.path_handler import PathHandler
# First, try to find existing compatdata
compatdata_path = PathHandler.find_compat_data(str(appid))
if compatdata_path:
return compatdata_path
# Prefix doesn't exist yet - determine where to create it from libraryfolders.vdf
library_paths = PathHandler.get_all_steam_library_paths()
if library_paths:
# Use the first library (typically the default library)
# Construct compatdata path: library_path/steamapps/compatdata/appid
first_library = library_paths[0]
compatdata_base = first_library / "steamapps" / "compatdata"
return compatdata_base / str(appid)
# Only fallback if VDF parsing completely fails
logger.warning("Could not get library paths from libraryfolders.vdf, using fallback locations")
fallback_bases = [
Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam/steamapps/compatdata",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/compatdata",
Path.home() / ".steam/steam/steamapps/compatdata",
Path.home() / ".local/share/Steam/steamapps/compatdata",
]
for base_path in fallback_bases:
if base_path.is_dir():
return base_path / str(appid)
return None
def verify_prefix_creation(self, prefix_path: Path) -> bool:
"""
Verify that the prefix was created successfully.
Args:
prefix_path: Path to the prefix directory
Returns:
True if prefix is valid, False otherwise
"""
try:
logger.info(f"Verifying prefix: {prefix_path}")
# Check if prefix exists and has proper structure
if not prefix_path.exists():
logger.error("Prefix directory does not exist")
return False
pfx_dir = prefix_path / "pfx"
if not pfx_dir.exists():
logger.error("Prefix exists but no pfx subdirectory")
return False
# Check for key Wine files
system_reg = pfx_dir / "system.reg"
user_reg = pfx_dir / "user.reg"
drive_c = pfx_dir / "drive_c"
if not system_reg.exists():
logger.error("No system.reg found in prefix")
return False
if not user_reg.exists():
logger.error("No user.reg found in prefix")
return False
if not drive_c.exists():
logger.error("No drive_c directory found in prefix")
return False
logger.info("Prefix structure verified successfully")
return True
except Exception as e:
logger.error(f"Error verifying prefix: {e}")
return False
def wait_for_prefix_completion(self, prefix_id: str, timeout: int = 60) -> bool:
"""
Wait for system.reg to stop growing (indicates prefix creation is complete).
Args:
prefix_id: The Steam prefix ID to monitor
timeout: Maximum seconds to wait
Returns:
True if prefix creation completed, False if timeout
"""
try:
prefix_path = Path.home() / f".local/share/Steam/steamapps/compatdata/{prefix_id}"
system_reg = prefix_path / "pfx/system.reg"
logger.info(f"Monitoring prefix completion: {system_reg}")
last_size = 0
stable_count = 0
for i in range(timeout):
if system_reg.exists():
current_size = system_reg.stat().st_size
logger.debug(f"system.reg size: {current_size} bytes")
if current_size == last_size:
stable_count += 1
if stable_count >= 3: # Stable for 3 seconds
logger.info(" system.reg size stable - prefix creation complete")
return True
else:
stable_count = 0
last_size = current_size
time.sleep(1)
logger.warning(f"Timeout waiting for prefix completion after {timeout} seconds")
return False
except Exception as e:
logger.error(f"Error monitoring prefix completion: {e}")
return False
def create_prefix_with_proton_wrapper(self, appid: int) -> bool:
"""
Create a Proton prefix directly using Proton's wrapper and STEAM_COMPAT_DATA_PATH.
Args:
appid: The AppID to create the prefix for
Returns:
True if successful, False otherwise
"""
try:
# Determine Steam locations based on installation type
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
all_libraries = path_handler.get_all_steam_library_paths()
# Check if we have Flatpak Steam by looking for .var/app/com.valvesoftware.Steam in library paths
is_flatpak_steam = any('.var/app/com.valvesoftware.Steam' in str(lib) for lib in all_libraries)
if is_flatpak_steam and all_libraries:
# Flatpak Steam: Use the actual library root from libraryfolders.vdf
# Compatdata should be in the library root, not the client root
flatpak_library_root = all_libraries[0] # Use first library (typically the default)
flatpak_client_root = flatpak_library_root.parent.parent / ".steam/steam"
if not flatpak_library_root.is_dir():
logger.error(
f"Flatpak Steam library root does not exist: {flatpak_library_root}"
)
return False
steam_root = flatpak_client_root if flatpak_client_root.is_dir() else flatpak_library_root
# CRITICAL: compatdata must be in the library root, not client root
compatdata_dir = flatpak_library_root / "steamapps/compatdata"
proton_common_dir = flatpak_library_root / "steamapps/common"
else:
# Native Steam (or unknown): fall back to legacy ~/.steam/steam layout
steam_root = Path.home() / ".steam/steam"
compatdata_dir = steam_root / "steamapps/compatdata"
proton_common_dir = steam_root / "steamapps/common"
# Ensure compatdata root exists and is a directory we actually want to use
if not compatdata_dir.is_dir():
logger.error(f"Compatdata root does not exist: {compatdata_dir}. Aborting prefix creation.")
return False
# Find a Proton wrapper to use
proton_path = self._find_proton_binary(proton_common_dir)
if not proton_path:
logger.error("No Proton wrapper found")
return False
# Set up environment variables
env = os.environ.copy()
env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(steam_root)
env['STEAM_COMPAT_DATA_PATH'] = str(compatdata_dir / str(abs(appid)))
# Suppress GUI windows using jackify-engine's proven approach
env['DISPLAY'] = ''
env['WAYLAND_DISPLAY'] = ''
env['WINEDEBUG'] = '-all'
env['WINEDLLOVERRIDES'] = 'msdia80.dll=n;conhost.exe=d;cmd.exe=d'
# Create the compatdata directory for this AppID (but never the whole tree)
compat_dir = compatdata_dir / str(abs(appid))
compat_dir.mkdir(exist_ok=True)
logger.info(f"Creating Proton prefix for AppID {appid}")
logger.info(f"STEAM_COMPAT_CLIENT_INSTALL_PATH={env['STEAM_COMPAT_CLIENT_INSTALL_PATH']}")
logger.info(f"STEAM_COMPAT_DATA_PATH={env['STEAM_COMPAT_DATA_PATH']}")
# Run proton run wineboot -u to initialize the prefix
cmd = [str(proton_path), 'run', 'wineboot', '-u']
logger.info(f"Running: {' '.join(cmd)}")
# Adjust timeout for SD card installations on Steam Deck (slower I/O)
from ..services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
is_steamdeck_sdcard = (platform_service.is_steamdeck and
str(proton_path).startswith('/run/media/'))
timeout = 180 if is_steamdeck_sdcard else 60
if is_steamdeck_sdcard:
logger.info(f"Using extended timeout ({timeout}s) for Steam Deck SD card Proton installation")
# Use jackify-engine's approach: UseShellExecute=false, CreateNoWindow=true equivalent
result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=timeout,
shell=False, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
logger.info(f"Proton exit code: {result.returncode}")
if result.stdout:
logger.info(f"stdout: {result.stdout.strip()[:500]}")
if result.stderr:
logger.info(f"stderr: {result.stderr.strip()[:500]}")
# Give a moment for files to land
time.sleep(3)
# Check if prefix was created
pfx = compat_dir / 'pfx'
if pfx.exists():
logger.info(f" Proton prefix created at: {pfx}")
return True
else:
logger.warning(f"Proton prefix not found at: {pfx}")
return False
except subprocess.TimeoutExpired:
logger.warning("Proton timed out; prefix may still be initializing")
return False
except Exception as e:
logger.error(f"Error creating prefix: {e}")
return False

View File

@@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""
Game utilities mixin for AutomatedPrefixService.
Handles game-specific operations:
- Launch options generation
- Game detection
- User directory creation
- Proton version preferences
"""
import os
import logging
from pathlib import Path
from typing import Optional, List
logger = logging.getLogger(__name__)
class GameUtilsMixin:
"""Mixin for game-related utility operations"""
def _generate_special_game_launch_options(self, special_game_type: str, modlist_install_dir: str) -> Optional[str]:
"""
Generate launch options for FNV/Enderal games that require vanilla compatdata.
Args:
special_game_type: "fnv" or "enderal"
modlist_install_dir: Directory where the modlist is installed
Returns:
Complete launch options string with STEAM_COMPAT_DATA_PATH, or None if failed
"""
if not special_game_type or special_game_type not in ["fnv", "enderal"]:
return None
logger.info(f"Generating {special_game_type.upper()} launch options")
# Map game types to AppIDs
appid_map = {"fnv": "22380", "enderal": "976620"}
appid = appid_map[special_game_type]
# Find vanilla game compatdata
from ..handlers.path_handler import PathHandler
compatdata_path = PathHandler.find_compat_data(appid)
if not compatdata_path:
logger.error(f"Could not find vanilla {special_game_type.upper()} compatdata directory (AppID {appid})")
return None
# Create STEAM_COMPAT_DATA_PATH string
compat_data_str = f'STEAM_COMPAT_DATA_PATH="{compatdata_path}"'
# Generate STEAM_COMPAT_MOUNTS if multiple libraries exist
compat_mounts_str = ""
try:
all_libs = PathHandler.get_all_steam_library_paths()
main_steam_lib_path_obj = PathHandler.find_steam_library()
if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common":
main_steam_lib_path = main_steam_lib_path_obj.parent.parent
else:
main_steam_lib_path = main_steam_lib_path_obj
mount_paths = []
if main_steam_lib_path:
main_resolved = main_steam_lib_path.resolve()
for lib_path in all_libs:
if lib_path.resolve() != main_resolved:
mount_paths.append(str(lib_path.resolve()))
if mount_paths:
mount_paths_str = ':'.join(mount_paths)
compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{mount_paths_str}"'
logger.info(f"Added STEAM_COMPAT_MOUNTS for {special_game_type.upper()}")
except Exception as e:
logger.warning(f"Error generating STEAM_COMPAT_MOUNTS for {special_game_type}: {e}")
# Combine all launch options
launch_options = f"{compat_mounts_str} {compat_data_str} %command%".strip()
launch_options = ' '.join(launch_options.split()) # Clean up spacing
logger.info(f"Generated {special_game_type.upper()} launch options: {launch_options}")
return launch_options
def _find_steam_game(self, app_id: str, common_names: list) -> Optional[str]:
"""Find a Steam game installation path by AppID and common names"""
import os
from pathlib import Path
# Get Steam libraries from libraryfolders.vdf - check multiple possible locations
possible_config_paths = [
Path.home() / ".steam/steam/config/libraryfolders.vdf",
Path.home() / ".local/share/Steam/config/libraryfolders.vdf",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf" # Flatpak
]
steam_config_path = None
for path in possible_config_paths:
if path.exists():
steam_config_path = path
break
if not steam_config_path:
return None
steam_libraries = []
try:
with open(steam_config_path, 'r') as f:
content = f.read()
# Parse library paths from VDF
import re
library_matches = re.findall(r'"path"\s+"([^"]+)"', content)
steam_libraries = [Path(path) / "steamapps" / "common" for path in library_matches]
except Exception as e:
logger.warning(f"Failed to parse Steam library folders: {e}")
return None
# Search for game in each library
for library_path in steam_libraries:
if not library_path.exists():
continue
# Check manifest file first (more reliable)
manifest_path = library_path.parent / "appmanifest_{}.acf".format(app_id)
if manifest_path.exists():
try:
with open(manifest_path, 'r') as f:
content = f.read()
install_dir_match = re.search(r'"installdir"\s+"([^"]+)"', content)
if install_dir_match:
game_path = library_path / install_dir_match.group(1)
if game_path.exists():
return str(game_path)
except Exception:
pass
# Fallback: check common folder names
for name in common_names:
game_path = library_path / name
if game_path.exists():
return str(game_path)
return None
def _create_game_user_directories(self, modlist_compatdata_path: str, special_game_type: str):
"""
Pre-create game-specific user directories to prevent first-launch issues.
Creates both My Documents/My Games and AppData/Local directories for the game.
This prevents issues where games fail to create these on first launch under Proton.
"""
# Map game types to their directory names
game_dir_names = {
"skyrim": "Skyrim Special Edition",
"fnv": "FalloutNV",
"fo4": "Fallout4",
"oblivion": "Oblivion",
"oblivion_remastered": "Oblivion Remastered",
"enderal": "Enderal Special Edition",
"starfield": "Starfield"
}
# Get the directory name for this game type
game_dir_name = game_dir_names.get(special_game_type)
if not game_dir_name:
logger.debug(f"No user directory mapping for game type: {special_game_type}")
return
base_path = os.path.join(modlist_compatdata_path, "pfx", "drive_c", "users", "steamuser")
directories_to_create = [
os.path.join(base_path, "Documents", "My Games", game_dir_name),
os.path.join(base_path, "AppData", "Local", game_dir_name)
]
created_count = 0
for directory in directories_to_create:
try:
os.makedirs(directory, exist_ok=True)
logger.info(f"Created user directory: {directory}")
created_count += 1
except Exception as e:
logger.warning(f"Failed to create directory {directory}: {e}")
if created_count > 0:
logger.info(f"Created {created_count} user directories for {game_dir_name}")
def _get_lorerim_preferred_proton(self):
"""Get Lorerim's preferred Proton 9 version with specific priority order"""
try:
from jackify.backend.handlers.wine_utils import WineUtils
# Get all available Proton versions
available_versions = WineUtils.scan_all_proton_versions()
if not available_versions:
logger.warning("No Proton versions found for Lorerim override")
return None
# Priority order for Lorerim:
# 1. GEProton9-27 (specific version)
# 2. Other GEProton-9 versions (latest first)
# 3. Valve Proton 9 (any version)
preferred_candidates = []
for version in available_versions:
version_name = version['name']
# Priority 1: GEProton9-27 specifically
if version_name == 'GE-Proton9-27':
logger.info(f"Lorerim: Found preferred GE-Proton9-27")
return version_name
# Priority 2: Other GE-Proton 9 versions
elif version_name.startswith('GE-Proton9-'):
preferred_candidates.append(('ge_proton_9', version_name, version))
# Priority 3: Valve Proton 9
elif 'Proton 9' in version_name:
preferred_candidates.append(('valve_proton_9', version_name, version))
# Return best candidate if any found
if preferred_candidates:
# Sort by priority (GE-Proton first, then by name for latest)
preferred_candidates.sort(key=lambda x: (x[0], x[1]), reverse=True)
best_candidate = preferred_candidates[0]
logger.info(f"Lorerim: Selected {best_candidate[1]} as best Proton 9 option")
return best_candidate[1]
logger.warning("Lorerim: No suitable Proton 9 versions found, will use user settings")
return None
except Exception as e:
logger.error(f"Error detecting Lorerim Proton preference: {e}")
return None
def _store_proton_override_notification(self, modlist_name: str, proton_version: str):
"""Store Proton override information for end-of-install notification"""
try:
# Store override info for later display
if not hasattr(self, '_proton_overrides'):
self._proton_overrides = []
self._proton_overrides.append({
'modlist': modlist_name,
'proton_version': proton_version,
'reason': f'{modlist_name} requires Proton 9 for optimal compatibility'
})
logger.debug(f"Stored Proton override notification: {modlist_name}{proton_version}")
except Exception as e:
logger.error(f"Failed to store Proton override notification: {e}")
def _show_proton_override_notification(self, progress_callback=None):
"""Display any Proton override notifications to the user"""
try:
if hasattr(self, '_proton_overrides') and self._proton_overrides:
for override in self._proton_overrides:
notification_msg = f"PROTON OVERRIDE: {override['modlist']} configured to use {override['proton_version']} for optimal compatibility"
if progress_callback:
progress_callback("")
progress_callback(f"{self._get_progress_timestamp()} {notification_msg}")
logger.info(notification_msg)
# Clear notifications after display
self._proton_overrides = []
except Exception as e:
logger.error(f"Failed to show Proton override notification: {e}")

View File

@@ -0,0 +1,673 @@
"""Proton/compatibility tool methods for AutomatedPrefixService (Mixin)."""
from pathlib import Path
from typing import Optional
import logging
import os
import vdf
logger = logging.getLogger(__name__)
def debug_print(message):
"""Log debug message only if debug mode is enabled"""
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
if config_handler.get('debug_mode', False):
logger.debug(message)
class ProtonOperationsMixin:
"""Mixin providing Proton and compatibility tool methods for AutomatedPrefixService."""
def _get_user_proton_version(self, modlist_name: str = None):
"""Get user's preferred Proton version from config, with fallback to auto-detection
Args:
modlist_name: Optional modlist name for special handling (e.g., Lorerim)
"""
try:
from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.backend.handlers.wine_utils import WineUtils
# Check for Lorerim-specific Proton override first
modlist_normalized = modlist_name.lower().replace(" ", "") if modlist_name else ""
if modlist_normalized == 'lorerim':
lorerim_proton = self._get_lorerim_preferred_proton()
if lorerim_proton:
logger.info(f"Lorerim detected: Using {lorerim_proton} instead of user settings")
self._store_proton_override_notification("Lorerim", lorerim_proton)
return lorerim_proton
# Check for Lost Legacy-specific Proton override (needs Proton 9 for ENB compatibility)
if modlist_normalized == 'lostlegacy':
lostlegacy_proton = self._get_lorerim_preferred_proton() # Use same logic as Lorerim
if lostlegacy_proton:
logger.info(f"Lost Legacy detected: Using {lostlegacy_proton} instead of user settings (ENB compatibility)")
self._store_proton_override_notification("Lost Legacy", lostlegacy_proton)
return lostlegacy_proton
config_handler = ConfigHandler()
user_proton_path = config_handler.get_game_proton_path()
if not user_proton_path or user_proton_path == 'auto':
logger.info("User selected auto-detect, using GE-Proton → Experimental → Proton precedence")
best = WineUtils.select_best_proton()
if best:
compat_name = best.get('steam_compat_name') or WineUtils.resolve_steam_compat_name(best['path'])
if compat_name:
logger.info(f"Auto-detected Proton: {compat_name}")
return compat_name
return "proton_experimental"
else:
# Resolve the actual Steam internal name from the Proton installation
resolved = WineUtils.resolve_steam_compat_name(user_proton_path)
if resolved:
logger.info(f"Using user-selected Proton: {resolved}")
return resolved
# Fallback for Proton installations without compatibilitytool.vdf
logger.warning(f"Could not resolve compat name for '{user_proton_path}', using basename")
proton_version = os.path.basename(user_proton_path)
if proton_version.startswith('GE-Proton'):
return proton_version
steam_proton_name = proton_version.lower().replace(' - ', '_').replace(' ', '_').replace('-', '_')
if not steam_proton_name.startswith('proton'):
steam_proton_name = f"proton_{steam_proton_name}"
logger.info(f"Using fallback Proton name: {steam_proton_name}")
return steam_proton_name
except Exception as e:
logger.error(f"Failed to get user Proton preference, using default: {e}")
return "proton_experimental"
def find_proton_experimental(self) -> Optional[Path]:
"""
Find Proton Experimental installation.
Returns:
Path to Proton Experimental, or None if not found
"""
proton_paths = [
Path.home() / ".local/share/Steam/steamapps/common/Proton - Experimental",
Path.home() / ".steam/steam/steamapps/common/Proton - Experimental",
Path.home() / ".local/share/Steam/steamapps/common/Proton Experimental",
Path.home() / ".steam/steam/steamapps/common/Proton Experimental",
]
for path in proton_paths:
if path.exists():
logger.info(f"Found Proton Experimental at: {path}")
return path
logger.error("Proton Experimental not found")
return None
def check_shortcut_proton_version(self, shortcut_name: str):
"""
Check if the shortcut has the Proton version set correctly.
Args:
shortcut_name: Name of the shortcut to check
"""
# STL sets the compatibility tool in config.vdf, not shortcuts.vdf
# We know this works from manual testing, so just log that we're skipping this check
logger.info(f"Skipping Proton version check for '{shortcut_name}' - STL handles this correctly")
debug_print(f"[DEBUG] Skipping Proton version check for '{shortcut_name}' - STL handles this correctly")
def set_proton_version_for_shortcut(self, appid: int, proton_version: str) -> bool:
"""
Set the Proton version for a shortcut in config.vdf.
Args:
appid: The AppID of the shortcut (negative for non-Steam shortcuts)
proton_version: The Proton version to set (e.g., 'proton_experimental')
Returns:
True if successful, False otherwise
"""
try:
# Get the config.vdf path
config_path = self._get_config_path()
if not config_path:
logger.error("No config.vdf path found")
return False
# Read current config (config.vdf is text format)
with open(config_path, 'r') as f:
config_data = vdf.load(f)
# Navigate to the correct location in the VDF structure
if 'Software' not in config_data:
config_data['Software'] = {}
if 'Valve' not in config_data['Software']:
config_data['Software']['Valve'] = {}
if 'Steam' not in config_data['Software']['Valve']:
config_data['Software']['Valve']['Steam'] = {}
# Get or create CompatToolMapping
if 'CompatToolMapping' not in config_data['Software']['Valve']['Steam']:
config_data['Software']['Valve']['Steam']['CompatToolMapping'] = {}
# Set the Proton version for this AppID using Steam's expected format
# Steam requires a dict with 'name', 'config', and 'priority' keys
config_data['Software']['Valve']['Steam']['CompatToolMapping'][str(appid)] = {
'name': proton_version,
'config': '',
'priority': '250'
}
# Write back to file (text format)
with open(config_path, 'w') as f:
vdf.dump(config_data, f)
# Ensure file is fully written to disk before Steam restart
import os
os.fsync(f.fileno()) if hasattr(f, 'fileno') else None
logger.info(f"Set Proton version {proton_version} for AppID {appid}")
debug_print(f"[DEBUG] Set Proton version {proton_version} for AppID {appid} in config.vdf")
# Small delay to ensure filesystem write completes
import time
time.sleep(0.5)
# Verify it was set correctly
with open(config_path, 'r') as f:
verify_data = vdf.load(f)
compat_mapping = verify_data.get('Software', {}).get('Valve', {}).get('Steam', {}).get('CompatToolMapping', {}).get(str(appid))
debug_print(f"[DEBUG] Verification: AppID {appid} -> {compat_mapping}")
return True
except Exception as e:
logger.error(f"Error setting Proton version: {e}")
return False
def set_compatool_on_shortcut(self, shortcut_name: str) -> bool:
"""
Set CompatTool on a shortcut immediately after STL creation.
This is CRITICAL to ensure the batch file shortcut has Proton set
so it can create a prefix when launched.
Args:
shortcut_name: Name of the shortcut to modify
Returns:
True if successful, False otherwise
"""
try:
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
return False
# Read current shortcuts
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
# Find the shortcut by name
for i in range(len(shortcuts)):
shortcut = shortcuts[str(i)]
name = shortcut.get('AppName', '')
if shortcut_name == name:
# Check current CompatTool setting
current_compat = shortcut.get('CompatTool', 'NOT_SET')
logger.info(f"Found shortcut '{name}' with CompatTool: '{current_compat}'")
# Set CompatTool to ensure batch file can create prefix
shortcut['CompatTool'] = 'proton_experimental'
logger.info(f" Set CompatTool=proton_experimental on shortcut: {name}")
# Write back to file
with open(shortcuts_path, 'wb') as f:
vdf.binary_dump(shortcuts_data, f)
return True
logger.error(f"Shortcut '{shortcut_name}' not found for CompatTool setting")
return False
except Exception as e:
logger.error(f"Error setting CompatTool on shortcut: {e}")
return False
def _set_proton_on_shortcut(self, shortcut_name: str) -> bool:
"""
Set Proton Experimental on a shortcut by name.
Args:
shortcut_name: Name of the shortcut to modify
Returns:
True if successful, False otherwise
"""
try:
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
return False
# Read current shortcuts
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
# Find the shortcut by name
for i in range(len(shortcuts)):
shortcut = shortcuts[str(i)]
name = shortcut.get('AppName', '')
if shortcut_name == name:
# Set CompatTool
shortcut['CompatTool'] = 'proton_experimental'
logger.info(f"Set CompatTool=proton_experimental on shortcut: {name}")
# Write back to file
with open(shortcuts_path, 'wb') as f:
vdf.binary_dump(shortcuts_data, f)
return True
logger.error(f"Shortcut '{shortcut_name}' not found for Proton setting")
return False
except Exception as e:
logger.error(f"Error setting Proton on shortcut: {e}")
return False
def set_compatibility_tool_stl_style(self, unsigned_appid: int, compat_tool: str) -> bool:
"""
Set compatibility tool using STL's exact method.
This adds an entry to config.vdf's CompatToolMapping section using the unsigned AppID as the key,
exactly like STL does.
Args:
unsigned_appid: The unsigned AppID (Grid ID) to use as the key
compat_tool: The compatibility tool name (e.g., 'proton_experimental')
Returns:
True if successful, False otherwise
"""
try:
config_path = self._get_config_path()
if not config_path:
logger.error("No config.vdf path found")
return False
# Read current config (config.vdf is text format)
with open(config_path, 'r') as f:
config_data = vdf.load(f)
# Navigate to the correct location in the VDF structure
if 'Software' not in config_data:
config_data['Software'] = {}
if 'Valve' not in config_data['Software']:
config_data['Software']['Valve'] = {}
if 'Steam' not in config_data['Software']['Valve']:
config_data['Software']['Valve']['Steam'] = {}
# Get or create CompatToolMapping
if 'CompatToolMapping' not in config_data['Software']['Valve']['Steam']:
config_data['Software']['Valve']['Steam']['CompatToolMapping'] = {}
# Create the compatibility tool entry exactly like STL does
compat_entry = {
'name': compat_tool,
'config': '',
'priority': '250'
}
# Set the compatibility tool for this AppID (using unsigned AppID as key)
config_data['Software']['Valve']['Steam']['CompatToolMapping'][str(unsigned_appid)] = compat_entry
logger.info(f"Added compatibility tool entry: {str(unsigned_appid)} -> {compat_tool}")
debug_print(f"[DEBUG] Added compatibility tool entry: {str(unsigned_appid)} -> {compat_tool}")
# Write back to file (text format)
with open(config_path, 'w') as f:
vdf.dump(config_data, f)
logger.info(f"Set compatibility tool STL-style: AppID {unsigned_appid} -> {compat_tool}")
debug_print(f"[DEBUG] Set compatibility tool STL-style: AppID {unsigned_appid} -> {compat_tool}")
return True
except Exception as e:
logger.error(f"Error setting compatibility tool STL-style: {e}")
return False
def set_compatibility_tool_complete_stl_style(self, unsigned_appid: int, compat_tool: str) -> bool:
"""
Set compatibility tool using STL's complete method with direct text manipulation.
This replicates STL's approach by using direct text manipulation instead of VDF libraries
to preserve existing entries in both config.vdf and localconfig.vdf.
Args:
unsigned_appid: The unsigned AppID (Grid ID) to use as the key
compat_tool: The compatibility tool name (e.g., 'proton_experimental')
Returns:
True if successful, False otherwise
"""
try:
# Step 1: Update config.vdf using direct text manipulation (like STL does)
config_path = self._get_config_path()
if not config_path:
logger.error("No config.vdf path found")
return False
# Read the entire file as text
with open(config_path, 'r') as f:
lines = f.readlines()
# Find the CompatToolMapping section
compat_section_start = None
compat_section_end = None
for i, line in enumerate(lines):
if '"CompatToolMapping"' in line.strip():
compat_section_start = i
# Find the end of the CompatToolMapping section
brace_count = 0
for j in range(i + 1, len(lines)):
if '{' in lines[j]:
brace_count += 1
if '}' in lines[j]:
brace_count -= 1
if brace_count == 0:
compat_section_end = j
break
break
if compat_section_start is None:
logger.error("CompatToolMapping section not found in config.vdf")
return False
# Check if our AppID entry already exists
appid_entry_start = None
appid_entry_end = None
for i in range(compat_section_start, compat_section_end + 1):
if f'"{unsigned_appid}"' in lines[i]:
appid_entry_start = i
# Find the end of this AppID entry
brace_count = 0
for j in range(i + 1, compat_section_end + 1):
if '{' in lines[j]:
brace_count += 1
if '}' in lines[j]:
brace_count -= 1
if brace_count == 0:
appid_entry_end = j
break
break
# Create the new entry in Steam's exact format
new_entry_lines = [
f'\t\t\t\t\t\t\t\t\t"{unsigned_appid}"\n',
f'\t\t\t\t\t\t\t\t\t{{\n',
f'\t\t\t\t\t\t\t\t\t\t"name"\t\t\t\t"{compat_tool}"\n',
f'\t\t\t\t\t\t\t\t\t\t"config"\t\t\t\t\t""\n',
f'\t\t\t\t\t\t\t\t\t\t"priority"\t\t\t\t\t"250"\n',
f'\t\t\t\t\t\t\t\t\t}}\n'
]
if appid_entry_start is None:
# AppID entry doesn't exist, add it before the closing brace of CompatToolMapping
lines.insert(compat_section_end, ''.join(new_entry_lines))
else:
# AppID entry exists, replace it
del lines[appid_entry_start:appid_entry_end + 1]
lines.insert(appid_entry_start, ''.join(new_entry_lines))
# Write the updated file back
with open(config_path, 'w') as f:
f.writelines(lines)
logger.info(f"Updated config.vdf: AppID {unsigned_appid} -> {compat_tool}")
# Step 2: Update localconfig.vdf using direct text manipulation (like STL)
localconfig_path = self._get_localconfig_path()
if not localconfig_path:
logger.error("No localconfig.vdf path found")
return False
# Calculate signed AppID (like STL does)
signed_appid = (unsigned_appid | 0x80000000) & 0xFFFFFFFF
# Convert to signed 32-bit integer
import ctypes
signed_appid_int = ctypes.c_int32(signed_appid).value
# Read the entire file as text
with open(localconfig_path, 'r') as f:
lines = f.readlines()
# Check if Apps section exists
apps_section_start = None
apps_section_end = None
for i, line in enumerate(lines):
if line.strip() == '"Apps"':
apps_section_start = i
# Find the end of the Apps section
brace_count = 0
for j in range(i + 1, len(lines)):
if '{' in lines[j]:
brace_count += 1
if '}' in lines[j]:
brace_count -= 1
if brace_count == 0:
apps_section_end = j
break
break
# If Apps section doesn't exist, create it at the end of the file
if apps_section_start is None:
logger.info("Apps section not found, creating it at the end of the file")
# Find the last closing brace (before the final closing brace)
last_brace_pos = None
for i in range(len(lines) - 1, -1, -1):
if lines[i].strip() == '}':
last_brace_pos = i
break
if last_brace_pos is None:
logger.error("Could not find closing brace in localconfig.vdf")
return False
# Insert Apps section before the last closing brace
apps_section = [
' "Apps"\n',
' {\n',
f' "{signed_appid_int}"\n',
' {\n',
' "OverlayAppEnable" "1"\n',
' "DisableLaunchInVR" "1"\n',
' }\n',
' }\n'
]
lines.insert(last_brace_pos, ''.join(apps_section))
else:
# Apps section exists, check if our AppID entry exists
appid_entry_start = None
appid_entry_end = None
for i in range(apps_section_start, apps_section_end + 1):
if f'"{signed_appid_int}"' in lines[i]:
appid_entry_start = i
# Find the end of this AppID entry
brace_count = 0
for j in range(i + 1, apps_section_end + 1):
if '{' in lines[j]:
brace_count += 1
if '}' in lines[j]:
brace_count -= 1
if brace_count == 0:
appid_entry_end = j
break
break
if appid_entry_start is None:
# AppID entry doesn't exist, add it to the Apps section
logger.info(f"AppID {signed_appid_int} entry not found, adding it to Apps section")
# Insert before the closing brace of the Apps section
appid_entry = [
f' "{signed_appid_int}"\n',
' {\n',
' "OverlayAppEnable" "1"\n',
' "DisableLaunchInVR" "1"\n',
' }\n'
]
lines.insert(apps_section_end, ''.join(appid_entry))
else:
# AppID entry exists, update the values
logger.info(f"AppID {signed_appid_int} entry exists, updating values")
# Check if the values already exist and update them
overlay_found = False
vr_found = False
for i in range(appid_entry_start, appid_entry_end + 1):
if '"OverlayAppEnable"' in lines[i]:
lines[i] = ' "OverlayAppEnable" "1"\n'
overlay_found = True
elif '"DisableLaunchInVR"' in lines[i]:
lines[i] = ' "DisableLaunchInVR" "1"\n'
vr_found = True
# Add missing values
if not overlay_found or not vr_found:
# Find the position to insert (before the closing brace of the AppID entry)
insert_pos = appid_entry_end
for i in range(appid_entry_start, appid_entry_end + 1):
if lines[i].strip() == '}':
insert_pos = i
break
new_values = []
if not overlay_found:
new_values.append(' "OverlayAppEnable" "1"\n')
if not vr_found:
new_values.append(' "DisableLaunchInVR" "1"\n')
for value in new_values:
lines.insert(insert_pos, value)
# Write the updated file back
with open(localconfig_path, 'w') as f:
f.writelines(lines)
logger.info(f"Updated localconfig.vdf: Signed AppID {signed_appid_int} -> OverlayAppEnable=1, DisableLaunchInVR=1")
debug_print(f"[DEBUG] Updated localconfig.vdf: Signed AppID {signed_appid_int} -> OverlayAppEnable=1, DisableLaunchInVR=1")
return True
except Exception as e:
logger.error(f"Error setting compatibility tool complete STL-style: {e}")
return False
def verify_compatibility_tool_persists(self, appid: int) -> bool:
"""
Verify that the compatibility tool setting persists with correct Proton version.
Args:
appid: The AppID to check
Returns:
True if compatibility tool is correctly set, False otherwise
"""
try:
config_path = Path.home() / ".steam/steam/config/config.vdf"
if not config_path.exists():
logger.warning("Steam config.vdf not found")
return False
with open(config_path, 'r', encoding='utf-8') as f:
content = f.read()
# Check if AppID exists and has a Proton version set
if f'"{appid}"' in content:
# Get the expected Proton version
expected_proton = self._get_user_proton_version()
# Look for the Proton version in the compatibility tool mapping
if expected_proton in content:
logger.info(f" Compatibility tool persists: {expected_proton}")
return True
else:
logger.warning(f"AppID {appid} found but Proton version '{expected_proton}' not set")
return False
else:
logger.warning("Compatibility tool not found")
return False
except Exception as e:
logger.error(f"Error verifying compatibility tool: {e}")
return False
def _find_proton_binary(self, proton_common_dir: Path) -> Optional[Path]:
"""Locate a Proton wrapper script to use, respecting user's configuration."""
try:
from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.backend.handlers.wine_utils import WineUtils
config = ConfigHandler()
user_proton_path = config.get_game_proton_path()
# If user selected a specific Proton, try that first
if user_proton_path != 'auto':
# Resolve symlinks to handle ~/.steam/steam -> ~/.local/share/Steam
resolved_proton_path = os.path.realpath(user_proton_path)
# Check for wine binary in different Proton structures
valve_proton_wine = Path(resolved_proton_path) / "dist" / "bin" / "wine"
ge_proton_wine = Path(resolved_proton_path) / "files" / "bin" / "wine"
if valve_proton_wine.exists() or ge_proton_wine.exists():
# Found user's Proton, now find the proton wrapper script
proton_wrapper = Path(resolved_proton_path) / "proton"
if proton_wrapper.exists():
logger.info(f"Using user-selected Proton wrapper: {proton_wrapper}")
return proton_wrapper
else:
logger.warning(f"User-selected Proton missing wrapper script: {proton_wrapper}")
else:
logger.warning(f"User-selected Proton path invalid: {user_proton_path}")
# Fall back to auto-detection
logger.info("Falling back to automatic Proton detection")
candidates = []
preferred = [
"Proton - Experimental",
"Proton 9.0",
"Proton 8.0",
"Proton Hotfix",
]
for name in preferred:
p = proton_common_dir / name / "proton"
if p.exists():
candidates.append(p)
# As a fallback, scan all Proton* dirs
if not candidates and proton_common_dir.exists():
for p in proton_common_dir.glob("Proton*/proton"):
candidates.append(p)
if not candidates:
logger.error("No Proton wrapper found under steamapps/common")
return None
logger.info(f"Using auto-detected Proton wrapper: {candidates[0]}")
return candidates[0]
except Exception as e:
logger.error(f"Error finding Proton binary: {e}")
return None

View File

@@ -0,0 +1,276 @@
"""Registry operations mixin for AutomatedPrefixService."""
import os
import subprocess
import logging
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
class RegistryOperationsMixin:
"""Mixin providing Wine/Proton registry operations."""
def _update_registry_path(self, system_reg_path: str, section_name: str, path_key: str, new_path: str) -> bool:
"""Update a specific path value in Wine registry, preserving other entries"""
if not os.path.exists(system_reg_path):
return False
try:
# Read existing content
with open(system_reg_path, 'r', encoding='utf-8', errors='ignore') as f:
lines = f.readlines()
in_target_section = False
path_updated = False
# Determine Wine drive letter based on SD card detection
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
from jackify.backend.handlers.path_handler import PathHandler
linux_path = Path(new_path)
if FileSystemHandler.is_sd_card(linux_path):
# SD card paths use D: drive
# Strip SD card prefix using the same method as other handlers
relative_sd_path_str = PathHandler._strip_sdcard_path_prefix(linux_path)
wine_path = relative_sd_path_str.replace('/', '\\\\')
wine_drive = "D:"
logger.debug(f"SD card path detected: {new_path} -> D:\\{wine_path}")
else:
# Regular paths use Z: drive with full path
wine_path = new_path.strip('/').replace('/', '\\\\')
wine_drive = "Z:"
logger.debug(f"Regular path: {new_path} -> Z:\\{wine_path}")
# Update existing path if found
for i, line in enumerate(lines):
stripped_line = line.strip()
# Case-insensitive comparison for section name (Wine registry is case-insensitive)
if stripped_line.split(']')[0].lower() + ']' == section_name.lower() if ']' in stripped_line else stripped_line.lower() == section_name.lower():
in_target_section = True
elif stripped_line.startswith('[') and in_target_section:
in_target_section = False
elif in_target_section and f'"{path_key}"' in line:
lines[i] = f'"{path_key}"="{wine_drive}\\\\{wine_path}\\\\"\n' # Add trailing backslashes
path_updated = True
break
# Add new section if path wasn't updated
if not path_updated:
lines.append(f'\n{section_name}\n')
lines.append(f'"{path_key}"="{wine_drive}\\\\{wine_path}\\\\"\n') # Add trailing backslashes
# Write updated content
with open(system_reg_path, 'w', encoding='utf-8') as f:
f.writelines(lines)
return True
except Exception as e:
logger.error(f"Failed to update registry path: {e}")
return False
def _apply_universal_dotnet_fixes(self, modlist_compatdata_path: str):
"""Apply universal dotnet4.x compatibility registry fixes to ALL modlists"""
try:
prefix_path = os.path.join(modlist_compatdata_path, "pfx")
if not os.path.exists(prefix_path):
logger.warning(f"Prefix path not found: {prefix_path}")
return False
logger.info("Applying universal dotnet4.x compatibility registry fixes...")
# Find the appropriate Wine binary to use for registry operations
wine_binary = self._find_wine_binary_for_registry(modlist_compatdata_path)
if not wine_binary:
logger.error("Could not find Wine binary for registry operations")
return False
# Set environment for Wine registry operations
env = os.environ.copy()
env['WINEPREFIX'] = prefix_path
env['WINEDEBUG'] = '-all' # Suppress Wine debug output
# Registry fix 1: Set *mscoree=native DLL override (asterisk for full override)
# Use native .NET runtime instead of Wine's
logger.debug("Setting *mscoree=native DLL override...")
cmd1 = [
wine_binary, 'reg', 'add',
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
'/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
]
result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True, errors='replace')
if result1.returncode == 0:
logger.info("Successfully applied *mscoree=native DLL override")
else:
logger.warning(f"Failed to set *mscoree DLL override: {result1.stderr}")
# Registry fix 2: Set OnlyUseLatestCLR=1
# Use latest CLR to avoid .NET version conflicts
logger.debug("Setting OnlyUseLatestCLR=1 registry entry...")
cmd2 = [
wine_binary, 'reg', 'add',
'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework',
'/v', 'OnlyUseLatestCLR', '/t', 'REG_DWORD', '/d', '1', '/f'
]
result2 = subprocess.run(cmd2, env=env, capture_output=True, text=True, errors='replace')
if result2.returncode == 0:
logger.info("Successfully applied OnlyUseLatestCLR=1 registry entry")
else:
logger.warning(f"Failed to set OnlyUseLatestCLR: {result2.stderr}")
# Both fixes applied - this should eliminate dotnet4.x installation requirements
if result1.returncode == 0 and result2.returncode == 0:
logger.info("Universal dotnet4.x compatibility fixes applied successfully")
return True
else:
logger.warning("Some dotnet4.x registry fixes failed, but continuing...")
return False
except Exception as e:
logger.error(f"Failed to apply universal dotnet4.x fixes: {e}")
return False
def _find_wine_binary_for_registry(self, modlist_compatdata_path: str) -> Optional[str]:
"""Find the appropriate Wine binary for registry operations"""
try:
from ..handlers.config_handler import ConfigHandler
from ..handlers.wine_utils import WineUtils
# Method 1: Use the user's configured Proton version from settings
config_handler = ConfigHandler()
user_proton_path = config_handler.get_game_proton_path()
if user_proton_path and user_proton_path != 'auto':
# User has selected a specific Proton version
proton_path = Path(user_proton_path).expanduser()
# Check for wine binary in both GE-Proton and Valve Proton structures
wine_candidates = [
proton_path / "files" / "bin" / "wine", # GE-Proton structure
proton_path / "dist" / "bin" / "wine" # Valve Proton structure
]
for wine_path in wine_candidates:
if wine_path.exists() and wine_path.is_file():
logger.info(f"Using Wine binary from user's configured Proton: {wine_path}")
return str(wine_path)
# Wine binary not found at expected paths - search recursively in Proton directory
logger.debug(f"Wine binary not found at expected paths in {proton_path}, searching recursively...")
wine_binary = self._search_wine_in_proton_directory(proton_path)
if wine_binary:
logger.info(f"Found Wine binary via recursive search in Proton directory: {wine_binary}")
return wine_binary
logger.warning(f"User's configured Proton path has no wine binary: {user_proton_path}")
# Method 2: Fallback to auto-detection using WineUtils
best_proton = WineUtils.select_best_proton()
if best_proton:
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
if wine_binary:
logger.info(f"Using Wine binary from detected Proton: {wine_binary}")
return wine_binary
# NEVER fall back to system wine - it will break Proton prefixes with architecture mismatches
logger.error("No suitable Proton Wine binary found for registry operations")
return None
except Exception as e:
logger.error(f"Error finding Wine binary: {e}")
return None
def _search_wine_in_proton_directory(self, proton_path: Path) -> Optional[str]:
"""
Recursively search for wine binary within a Proton directory.
This handles cases where the directory structure might differ between Proton versions.
Args:
proton_path: Path to the Proton directory to search
Returns:
Path to wine binary if found, None otherwise
"""
try:
if not proton_path.exists() or not proton_path.is_dir():
return None
# Search for 'wine' executable (not 'wine64' or 'wine-preloader')
# Limit search depth to avoid scanning entire filesystem
max_depth = 5
for root, dirs, files in os.walk(proton_path, followlinks=False):
# Calculate depth relative to proton_path
try:
depth = len(Path(root).relative_to(proton_path).parts)
except ValueError:
# Path is not relative to proton_path (shouldn't happen, but be safe)
continue
if depth > max_depth:
dirs.clear() # Don't descend further
continue
# Check if 'wine' is in this directory
if 'wine' in files:
wine_path = Path(root) / 'wine'
# Verify it's actually an executable file
if wine_path.is_file() and os.access(wine_path, os.X_OK):
logger.debug(f"Found wine binary at: {wine_path}")
return str(wine_path)
return None
except Exception as e:
logger.debug(f"Error during recursive wine search in {proton_path}: {e}")
return None
def _inject_game_registry_entries(self, modlist_compatdata_path: str, special_game_type: str):
"""Detect and inject FNV/Enderal game paths and apply universal dotnet4.x compatibility fixes"""
system_reg_path = os.path.join(modlist_compatdata_path, "pfx", "system.reg")
if not os.path.exists(system_reg_path):
logger.warning("system.reg not found, skipping game path injection")
return
logger.info("Detecting game registry entries...")
# Universal dotnet4.x registry fixes applied in modlist_handler.py after .reg downloads
# Game configurations
games_config = {
"22380": { # Fallout New Vegas AppID
"name": "Fallout New Vegas",
"common_names": ["Fallout New Vegas", "FalloutNV"],
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\falloutnv]",
"path_key": "Installed Path"
},
"976620": { # Enderal Special Edition AppID
"name": "Enderal",
"common_names": ["Enderal: Forgotten Stories (Special Edition)", "Enderal Special Edition", "Enderal"],
"registry_section": "[Software\\\\Wow6432Node\\\\SureAI\\\\Enderal SE]",
"path_key": "installed path"
}
}
# Detect and inject each game
for app_id, config in games_config.items():
game_path = self._find_steam_game(app_id, config["common_names"])
if game_path:
logger.info(f"Detected {config['name']} at: {game_path}")
success = self._update_registry_path(
system_reg_path,
config["registry_section"],
config["path_key"],
game_path
)
if success:
logger.info(f"Updated registry entry for {config['name']}")
else:
logger.warning(f"Failed to update registry entry for {config['name']}")
else:
logger.debug(f"{config['name']} not found in Steam libraries")
logger.info("Game registry injection completed")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,534 @@
"""Shortcut operation methods for AutomatedPrefixService (Mixin)."""
from pathlib import Path
from typing import Optional, Tuple, List, Dict
import logging
import os
import time
import vdf
import subprocess
from .automated_prefix_shortcuts_cleanup import AutomatedPrefixShortcutsCleanupMixin
logger = logging.getLogger(__name__)
def debug_print(message):
"""Log debug message only if debug mode is enabled"""
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
if config_handler.get('debug_mode', False):
logger.debug(message)
class ShortcutOperationsMixin(AutomatedPrefixShortcutsCleanupMixin):
"""Mixin providing shortcut operation methods for AutomatedPrefixService."""
def create_shortcut_with_native_service(self, shortcut_name: str, exe_path: str,
modlist_install_dir: str, custom_launch_options: str = None,
download_dir=None) -> Tuple[bool, Optional[int]]:
"""
Create a Steam shortcut using the native Steam service (no STL).
Args:
shortcut_name: Name for the shortcut
exe_path: Path to the executable
modlist_install_dir: Directory where the modlist is installed
custom_launch_options: Pre-generated launch options (overrides default generation)
download_dir: Optional download path; its mountpoint is added to STEAM_COMPAT_MOUNTS
Returns:
(success, unsigned_app_id)
"""
logger.info(f"Creating shortcut with native service: {shortcut_name}")
try:
from ..services.native_steam_service import NativeSteamService
# Initialize native Steam service
steam_service = NativeSteamService()
# Use custom launch options if provided, otherwise generate default
if custom_launch_options:
launch_options = custom_launch_options
logger.info(f"Using pre-generated launch options: {launch_options}")
else:
# Generate STEAM_COMPAT_MOUNTS including install and download mountpoints
launch_options = "%command%"
try:
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
mount_paths = path_handler.get_steam_compat_mount_paths(
install_dir=modlist_install_dir, download_dir=download_dir
)
if mount_paths:
launch_options = f'STEAM_COMPAT_MOUNTS="{":".join(mount_paths)}" %command%'
logger.info(f"Generated launch options with mounts: {launch_options}")
except Exception as e:
logger.warning(f"Could not generate STEAM_COMPAT_MOUNTS, using default: {e}")
launch_options = "%command%"
# Get user's preferred Proton version (with Lorerim-specific override)
proton_version = self._get_user_proton_version(shortcut_name)
# Create shortcut with Proton using native service
success, app_id = steam_service.create_shortcut_with_proton(
app_name=shortcut_name,
exe_path=exe_path,
start_dir=modlist_install_dir,
launch_options=launch_options,
tags=["Jackify"],
proton_version=proton_version
)
if success and app_id:
logger.info(f" Native Steam service created shortcut successfully with AppID: {app_id}")
return True, app_id
else:
logger.error("Native Steam service failed to create shortcut")
return False, None
except Exception as e:
logger.error(f"Error creating shortcut with native service: {e}")
return False, None
def verify_shortcut_created(self, shortcut_name: str) -> Optional[int]:
"""
Verify the shortcut was created and get its AppID.
Args:
shortcut_name: Name of the shortcut to look for
Returns:
AppID if found, None otherwise
"""
try:
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
return None
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
# Look for our shortcut by name
for i in range(len(shortcuts)):
shortcut = shortcuts[str(i)]
name = shortcut.get('AppName', '')
if shortcut_name in name:
appid = shortcut.get('appid')
exe_path = shortcut.get('Exe', '').strip('"')
logger.info(f"Found shortcut: {name}")
logger.info(f" AppID: {appid}")
logger.info(f" Exe: {exe_path}")
logger.info(f" CompatTool: {shortcut.get('CompatTool', 'NOT_SET')}")
return appid
logger.error(f"Shortcut '{shortcut_name}' not found")
return None
except Exception as e:
logger.error(f"Error reading shortcuts: {e}")
return None
def create_shortcut_directly(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> bool:
"""
Create a Steam shortcut directly by modifying shortcuts.vdf.
This is a fallback when STL fails.
Args:
shortcut_name: Name for the shortcut
exe_path: Path to the executable
modlist_install_dir: Directory where the modlist is installed
Returns:
True if successful, False otherwise
"""
try:
debug_print(f"[DEBUG] create_shortcut_directly called for '{shortcut_name}' - this is the fallback method")
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
debug_print("[DEBUG] No shortcuts path found")
return False
# Read current shortcuts
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
# Find the next available index
next_index = str(len(shortcuts))
# Calculate AppID for the new shortcut (negative for non-Steam shortcuts)
import hashlib
app_name_bytes = shortcut_name.encode('utf-8')
exe_bytes = exe_path.encode('utf-8')
combined = app_name_bytes + exe_bytes
hash_value = int(hashlib.md5(combined).hexdigest()[:8], 16)
appid = -(hash_value & 0x7FFFFFFF) # Make it negative and within 32-bit range
# Create new shortcut entry
new_shortcut = {
'AppName': shortcut_name,
'Exe': f'"{exe_path}"',
'StartDir': f'"{modlist_install_dir}"',
'appid': appid,
'icon': '',
'ShortcutPath': '',
'LaunchOptions': '',
'IsHidden': 0,
'AllowDesktopConfig': 1,
'AllowOverlay': 1,
'openvr': 0,
'Devkit': 0,
'DevkitGameID': '',
'LastPlayTime': 0,
'FlatpakAppID': '',
'tags': {},
'CompatTool': 'proton_experimental', # Set Proton Experimental
'IsInstalled': 1 # Make it appear in "Locally Installed" filter
}
# Add the new shortcut
shortcuts[next_index] = new_shortcut
# Write back to file
with open(shortcuts_path, 'wb') as f:
vdf.binary_dump(shortcuts_data, f)
logger.info(f"Created shortcut directly: {shortcut_name}")
return True
except Exception as e:
logger.error(f"Error creating shortcut directly: {e}")
return False
def create_shortcut_directly_with_proton(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> bool:
"""
Create a Steam shortcut with temporary batch file for invisible prefix creation.
This uses the CRC32-based AppID calculation for predictable results.
Args:
shortcut_name: Name for the shortcut
exe_path: Path to the final ModOrganizer.exe executable
modlist_install_dir: Directory where the modlist is installed
Returns:
True if successful, False otherwise
"""
try:
debug_print(f"[DEBUG] create_shortcut_directly_with_proton called for '{shortcut_name}' - using temporary batch file approach")
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
debug_print("[DEBUG] No shortcuts path found")
return False
# Calculate predictable AppID using CRC32 (based on FINAL exe_path)
from zlib import crc32
combined_string = exe_path + shortcut_name
crc = crc32(combined_string.encode('utf-8'))
appid = -(crc & 0x7FFFFFFF) # Make it negative and within 32-bit range (like other shortcuts)
debug_print(f"[DEBUG] Calculated AppID: {appid} from '{combined_string}'")
# Create temporary batch file for invisible prefix creation
batch_content = """@echo off
echo Creating Proton prefix...
timeout /t 3 /nobreak >nul
echo Prefix creation complete.
"""
from jackify.shared.paths import get_jackify_data_dir
batch_path = get_jackify_data_dir() / "temp_prefix_creation.bat"
batch_path.parent.mkdir(parents=True, exist_ok=True)
with open(batch_path, 'w') as f:
f.write(batch_content)
debug_print(f"[DEBUG] Created temporary batch file: {batch_path}")
# Read current shortcuts
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
# Check if shortcut already exists (idempotent)
found = False
new_shortcuts_list = []
shortcuts_list = list(shortcuts.values())
for shortcut in shortcuts_list:
if shortcut.get('AppName') == shortcut_name:
debug_print(f"[DEBUG] Updating existing shortcut for '{shortcut_name}'")
# Update existing shortcut with temporary batch file
shortcut.update({
'Exe': f'"{batch_path}"', # Point to temporary batch file
'StartDir': f'"{batch_path.parent}"', # Batch file directory
'appid': appid,
'LaunchOptions': '', # Empty like working shortcuts
'tags': {}, # Empty tags like working shortcuts
'CompatTool': 'proton_experimental' # Set Proton version directly in shortcut
})
new_shortcuts_list.append(shortcut)
found = True
else:
new_shortcuts_list.append(shortcut)
if not found:
debug_print(f"[DEBUG] Creating new shortcut for '{shortcut_name}'")
# Create new shortcut entry pointing to temporary batch file
new_shortcut = {
'AppName': shortcut_name,
'Exe': f'"{batch_path}"', # Point to temporary batch file
'StartDir': f'"{batch_path.parent}"', # Batch file directory
'appid': appid,
'icon': '',
'ShortcutPath': '',
'LaunchOptions': '', # Empty like working shortcuts
'IsHidden': 0,
'AllowDesktopConfig': 1,
'AllowOverlay': 1,
'OpenVR': 0,
'Devkit': 0,
'DevkitGameID': '',
'LastPlayTime': 0,
'FlatpakAppID': '',
'tags': {}, # Empty tags like working shortcuts
'sortas': '',
'CompatTool': 'proton_experimental' # Set Proton version directly in shortcut
}
new_shortcuts_list.append(new_shortcut)
# Rebuild shortcuts dict with new order
shortcuts_data['shortcuts'] = {str(i): s for i, s in enumerate(new_shortcuts_list)}
# Write back to file
with open(shortcuts_path, 'wb') as f:
vdf.binary_dump(shortcuts_data, f)
logger.info(f"Created/updated shortcut with temporary batch file: {shortcut_name} with AppID {appid}")
debug_print(f"[DEBUG] Shortcut created/updated with temporary batch file, AppID {appid}")
# Set Proton version in config.vdf BEFORE creating shortcut
if self.set_proton_version_for_shortcut(appid, 'proton_experimental'):
logger.info(f"Set Proton Experimental for shortcut {shortcut_name}")
return True
else:
logger.warning(f"Failed to set Proton version for shortcut {shortcut_name}")
return False
except Exception as e:
logger.error(f"Error creating shortcut with temporary batch file: {e}")
return False
def replace_shortcut_with_final_exe(self, shortcut_name: str, final_exe_path: str, modlist_install_dir: str) -> bool:
"""
Replace the temporary batch file shortcut with the final ModOrganizer.exe.
This should be called after the prefix has been created.
Args:
shortcut_name: Name of the shortcut to update
final_exe_path: Path to the final ModOrganizer.exe executable
modlist_install_dir: Directory where the modlist is installed
Returns:
True if successful, False otherwise
"""
try:
debug_print(f"[DEBUG] replace_shortcut_with_final_exe called for '{shortcut_name}'")
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
debug_print("[DEBUG] No shortcuts path found")
return False
# Read current shortcuts
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
# Find and update the shortcut
found = False
new_shortcuts_list = []
shortcuts_list = list(shortcuts.values())
for shortcut in shortcuts_list:
if shortcut.get('AppName') == shortcut_name:
debug_print(f"[DEBUG] Replacing temporary batch file with final exe for '{shortcut_name}'")
# Update shortcut to point to final ModOrganizer.exe
shortcut.update({
'Exe': f'"{final_exe_path}"', # Point to final ModOrganizer.exe
'StartDir': modlist_install_dir, # ModOrganizer directory
'LaunchOptions': '', # Empty like working shortcuts
'tags': {}, # Empty tags like working shortcuts
# Keep existing appid and CompatibilityTool
})
new_shortcuts_list.append(shortcut)
found = True
else:
new_shortcuts_list.append(shortcut)
if not found:
logger.error(f"Shortcut '{shortcut_name}' not found for replacement")
return False
# Rebuild shortcuts dict with new order
shortcuts_data['shortcuts'] = {str(i): s for i, s in enumerate(new_shortcuts_list)}
# Write back to file
with open(shortcuts_path, 'wb') as f:
vdf.binary_dump(shortcuts_data, f)
logger.info(f"Replaced shortcut with final exe: {shortcut_name}")
debug_print(f"[DEBUG] Shortcut replaced with final ModOrganizer.exe")
return True
except Exception as e:
logger.error(f"Error replacing shortcut with final exe: {e}")
return False
def modify_shortcut_to_final_exe(self, shortcut_name: str, final_exe_path: str,
final_start_dir: str) -> bool:
"""
Update the existing batch file shortcut to point to the final executable.
This preserves the AppID and prefix association while changing the target.
Args:
shortcut_name: Name of the shortcut to modify
final_exe_path: Path to the final executable (e.g., ModOrganizer.exe)
final_start_dir: Start directory for the executable
Returns:
True if successful, False otherwise
"""
try:
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
return False
# Read current shortcuts
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
# Find the batch file shortcut that created the prefix
logger.info(f"Looking for batch file shortcut '{shortcut_name}' among {len(shortcuts)} shortcuts...")
target_shortcut = None
target_index = None
for i in range(len(shortcuts)):
shortcut = shortcuts[str(i)]
name = shortcut.get('AppName', '')
exe = shortcut.get('Exe', '')
# Find the specific shortcut that points to our batch file (handle quoted paths)
if (name == shortcut_name and
exe and 'prefix_creation_' in exe and (exe.endswith('.bat') or exe.endswith('.bat"'))):
target_shortcut = shortcut
target_index = str(i)
logger.info(f"Found batch file shortcut '{shortcut_name}' at index {i}")
logger.info(f" Current Exe: {exe}")
logger.info(f" Current StartDir: {shortcut.get('StartDir', '')}")
logger.info(f" Current CompatTool: {shortcut.get('CompatTool', 'NOT_SET')}")
logger.info(f" AppID: {shortcut.get('appid', 'NOT_SET')}")
break
if target_shortcut is None:
logger.error(f"No batch file shortcut found with name '{shortcut_name}'")
# Debug: show all available shortcuts
logger.debug("Available shortcuts:")
for i in range(len(shortcuts)):
shortcut = shortcuts[str(i)]
name = shortcut.get('AppName', '')
exe = shortcut.get('Exe', '')
logger.debug(f" [{i}] {name} -> {exe}")
return False
# Update the existing shortcut IN-PLACE (preserves AppID and all other fields)
logger.info(f"Updating shortcut at index {target_index} IN-PLACE...")
# Only change Exe and StartDir - preserve everything else including AppID
old_exe = target_shortcut.get('Exe', '')
old_start_dir = target_shortcut.get('StartDir', '')
target_shortcut['Exe'] = f'"{final_exe_path}"'
target_shortcut['StartDir'] = f'"{final_start_dir}"'
# Ensure CompatTool is set (STL should have set this, but make sure)
if not target_shortcut.get('CompatTool', '').strip():
target_shortcut['CompatTool'] = 'proton_experimental'
logger.info("Set CompatTool to proton_experimental (was not set)")
logger.info(f" Updated shortcut '{shortcut_name}' at index {target_index}:")
logger.info(f" Exe: {old_exe}{target_shortcut['Exe']}")
logger.info(f" StartDir: {old_start_dir}{target_shortcut['StartDir']}")
logger.info(f" AppID: {target_shortcut.get('appid', 'NOT_SET')} (preserved)")
logger.info(f" CompatTool: {target_shortcut.get('CompatTool', 'NOT_SET')} (preserved)")
# Write back to file
with open(shortcuts_path, 'wb') as f:
vdf.binary_dump(shortcuts_data, f)
logger.info(" Shortcut updated successfully - no duplicates created")
return True
except Exception as e:
logger.error(f"Error modifying shortcut: {e}")
return False
def verify_final_shortcut(self, shortcut_name: str, expected_exe_path: str) -> bool:
"""
Verify the shortcut now points to the final executable.
Args:
shortcut_name: Name of the shortcut to verify
expected_exe_path: Expected executable path
Returns:
True if shortcut is correct, False otherwise
"""
try:
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
return False
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
# Find our shortcut
for i in range(len(shortcuts)):
shortcut = shortcuts[str(i)]
name = shortcut.get('AppName', '')
if shortcut_name in name:
exe_path = shortcut.get('Exe', '')
start_dir = shortcut.get('StartDir', '')
logger.info(f"Final shortcut configuration:")
logger.info(f" Name: {name}")
logger.info(f" Exe: {exe_path}")
logger.info(f" StartDir: {start_dir}")
# Verify it points to the final executable
if expected_exe_path in exe_path:
logger.info("Shortcut correctly points to final executable")
return True
else:
logger.error("Shortcut does not point to final executable")
return False
logger.error(f"Shortcut '{shortcut_name}' not found")
return False
except Exception as e:
logger.error(f"Error reading shortcuts: {e}")
return False

View File

@@ -0,0 +1,138 @@
"""Cleanup and replacement logic for shortcut operations (Mixin)."""
from pathlib import Path
from typing import Optional, Tuple
import logging
import os
import vdf
import subprocess
logger = logging.getLogger(__name__)
class AutomatedPrefixShortcutsCleanupMixin:
"""Mixin providing cleanup_old_batch_shortcuts, modify_shortcut_target, replace_existing_shortcut."""
def cleanup_old_batch_shortcuts(self, shortcut_name: str) -> bool:
"""Remove old batch file shortcuts for this modlist to prevent duplicates."""
try:
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
return False
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
indices_to_remove = []
for i in range(len(shortcuts)):
shortcut = shortcuts[str(i)]
name = shortcut.get('AppName', '')
exe = shortcut.get('Exe', '')
if (name == shortcut_name and
'prefix_creation_' in exe and
exe.endswith('.bat')):
indices_to_remove.append(str(i))
logger.info(f"Marking old batch shortcut for removal: {name} -> {exe}")
if not indices_to_remove:
logger.debug(f"No old batch shortcuts found for '{shortcut_name}'")
return True
new_shortcuts = {}
new_index = 0
for i in range(len(shortcuts)):
if str(i) not in indices_to_remove:
new_shortcuts[str(new_index)] = shortcuts[str(i)]
new_index += 1
shortcuts_data['shortcuts'] = new_shortcuts
with open(shortcuts_path, 'wb') as f:
vdf.binary_dump(shortcuts_data, f)
logger.info(f"Cleaned up {len(indices_to_remove)} old batch shortcuts for '{shortcut_name}'")
return True
except Exception as e:
logger.error(f"Error cleaning up old shortcuts: {e}")
return False
def modify_shortcut_target(self, shortcut_name: str, new_exe_path: str, new_start_dir: str) -> bool:
"""Modify an existing shortcut's target and start directory. Preserves launch options."""
try:
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
logger.error("No shortcuts.vdf path found")
return False
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
if 'shortcuts' not in shortcuts_data:
logger.error("No shortcuts found in shortcuts.vdf")
return False
shortcuts = shortcuts_data['shortcuts']
shortcut_found = False
for i in range(len(shortcuts)):
shortcut = shortcuts[str(i)]
if shortcut.get('AppName', '') == shortcut_name:
existing_launch_options = shortcut.get('LaunchOptions', '')
shortcut['Exe'] = new_exe_path
shortcut['StartDir'] = new_start_dir
shortcut['LaunchOptions'] = existing_launch_options
shortcut_found = True
logger.info(f"Modified shortcut '{shortcut_name}' to target: {new_exe_path}")
logger.info(f"Preserved launch options: {existing_launch_options}")
break
if not shortcut_found:
logger.error(f"Shortcut '{shortcut_name}' not found in shortcuts.vdf")
return False
with open(shortcuts_path, 'wb') as f:
vdf.binary_dump(shortcuts_data, f)
logger.info(f"Successfully modified shortcut '{shortcut_name}'")
return True
except Exception as e:
logger.error(f"Error modifying shortcut: {e}")
return False
def replace_existing_shortcut(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> Tuple[bool, Optional[int]]:
"""Replace an existing shortcut with a new one using STL, then create via native service."""
try:
logger.info(f"Replacing existing shortcut: {shortcut_name}")
appdir = os.environ.get('APPDIR')
if appdir:
stl_path = Path(appdir) / "opt" / "jackify" / "steamtinkerlaunch"
else:
project_root = Path(__file__).parent.parent.parent.parent.parent
stl_path = project_root / "external_repos/steamtinkerlaunch/steamtinkerlaunch"
if not stl_path.exists():
logger.error(f"STL not found at: {stl_path}")
return False, None
remove_cmd = [str(stl_path), "rnsg", f"--appname={shortcut_name}"]
env = os.environ.copy()
env['STL_QUIET'] = '1'
logger.info(f"Removing existing shortcut: {' '.join(remove_cmd)}")
result = subprocess.run(remove_cmd, capture_output=True, text=True, timeout=30, env=env)
if result.returncode != 0:
logger.warning(f"Failed to remove existing shortcut: {result.stderr}")
success, app_id = self.create_shortcut_with_native_service(shortcut_name, exe_path, modlist_install_dir)
return success, app_id
except Exception as e:
logger.error(f"Error replacing shortcut: {e}")
return False, None

View File

@@ -0,0 +1,190 @@
"""STL algorithm methods for AutomatedPrefixService (Mixin)."""
from pathlib import Path
from typing import Optional
import logging
import vdf
import binascii
logger = logging.getLogger(__name__)
class STLAlgorithmMixin:
"""Mixin providing Steam Tools Library algorithm methods for AutomatedPrefixService."""
def generate_steam_short_id(self, signed_appid: int) -> int:
"""
Convert signed 32-bit integer to unsigned 32-bit integer (same as STL's generateSteamShortID).
Args:
signed_appid: Signed 32-bit integer AppID
Returns:
Unsigned 32-bit integer AppID
"""
return signed_appid & 0xFFFFFFFF
def find_appid_in_shortcuts_vdf(self, shortcut_name: str) -> Optional[str]:
"""
Find the AppID for a shortcut by name directly in shortcuts.vdf.
This is a fallback method when protontricks detection fails.
Args:
shortcut_name: Name of the shortcut to find
Returns:
AppID as string, or None if not found
"""
try:
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
return None
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
# Look for shortcut by name
for i in range(len(shortcuts)):
shortcut = shortcuts[str(i)]
name = shortcut.get('AppName', '')
if shortcut_name == name:
appid = shortcut.get('appid')
if appid:
logger.info(f"Found AppID {appid} for shortcut '{shortcut_name}' in shortcuts.vdf")
return str(appid)
logger.warning(f"Shortcut '{shortcut_name}' not found in shortcuts.vdf")
return None
except Exception as e:
logger.error(f"Error finding AppID in shortcuts.vdf: {e}")
return None
def predict_appid_using_stl_algorithm(self, shortcut_name: str, exe_path: str) -> Optional[int]:
"""
Predict the AppID using SteamTinkerLaunch's exact algorithm.
This implements the same logic as STL's generateShortcutVDFAppId and generateSteamShortID functions:
1. Combine AppName + ExePath
2. Generate MD5 hash, take first 8 characters
3. Convert to decimal, make negative, ensure < 1 billion
4. Convert to unsigned 32-bit integer
Args:
shortcut_name: Name of the shortcut
exe_path: Path to the executable
Returns:
Predicted AppID as integer, or None if failed
"""
try:
import hashlib
# Step 1: Combine AppName + ExePath (exactly like STL)
combined_string = f"{shortcut_name}{exe_path}"
logger.debug(f"Combined string for AppID prediction: '{combined_string}'")
# Step 2: Generate MD5 hash and take first 8 characters
md5_hash = hashlib.md5(combined_string.encode()).hexdigest()
seed_hex = md5_hash[:8]
logger.debug(f"MD5 hash: {md5_hash}, seed hex: {seed_hex}")
# Step 3: Convert to decimal, make negative, ensure < 1 billion
seed_decimal = int(seed_hex, 16)
signed_appid = -(seed_decimal % 1000000000)
logger.debug(f"Seed decimal: {seed_decimal}, signed AppID: {signed_appid}")
# Step 4: Convert to unsigned 32-bit integer (STL's generateSteamShortID)
unsigned_appid = signed_appid & 0xFFFFFFFF
logger.debug(f"Unsigned AppID: {unsigned_appid}")
logger.info(f"Predicted AppID using STL algorithm: {unsigned_appid} (signed: {signed_appid})")
return unsigned_appid
except Exception as e:
logger.error(f"Error predicting AppID using STL algorithm: {e}")
return None
def create_shortcut_with_stl_algorithm(self, shortcut_name: str, exe_path: str, start_dir: str, compatibility_tool: str = None) -> bool:
"""
Create a shortcut using STL's exact algorithm for consistent AppID calculation.
Args:
shortcut_name: Name of the shortcut
exe_path: Path to the executable
start_dir: Start directory
compatibility_tool: Optional compatibility tool to set immediately (like STL does)
Returns:
True if successful, False otherwise
"""
try:
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
return False
# Read current shortcuts
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
# Find the next available index
next_index = str(len(shortcuts))
# Calculate AppID using STL's algorithm
predicted_appid = self.predict_appid_using_stl_algorithm(shortcut_name, exe_path)
if not predicted_appid:
logger.error("Failed to predict AppID for shortcut creation")
return False
# Convert to signed AppID (STL stores the signed version in shortcuts.vdf)
signed_appid = predicted_appid
if predicted_appid > 0x7FFFFFFF: # If it's a large positive number, make it negative
signed_appid = predicted_appid - 0x100000000
# Create new shortcut entry
new_shortcut = {
'AppName': shortcut_name,
'Exe': f'"{exe_path}"',
'StartDir': f'"{start_dir}"',
'appid': signed_appid, # Use the signed AppID
'icon': '',
'ShortcutPath': '',
'LaunchOptions': '',
'IsHidden': 0,
'AllowDesktopConfig': 1,
'AllowOverlay': 1,
'openvr': 0,
'Devkit': 0,
'DevkitGameID': '',
'LastPlayTime': 0,
'FlatpakAppID': '',
'tags': {},
'IsInstalled': 1 # Make it appear in "Locally Installed" filter
}
# Add the new shortcut
shortcuts[next_index] = new_shortcut
# Write back to file
with open(shortcuts_path, 'wb') as f:
vdf.binary_dump(shortcuts_data, f)
logger.info(f"Created shortcut with STL algorithm: {shortcut_name} with AppID {signed_appid} (unsigned: {predicted_appid})")
# Set compatibility tool immediately if provided (like STL does)
if compatibility_tool:
logger.info(f"Setting compatibility tool immediately: {compatibility_tool}")
success = self.set_compatibility_tool_complete_stl_style(predicted_appid, compatibility_tool)
if not success:
logger.warning("Failed to set compatibility tool immediately")
return True
except Exception as e:
logger.error(f"Error creating shortcut with STL algorithm: {e}")
return False

View File

@@ -0,0 +1,556 @@
"""Workflow methods for AutomatedPrefixService (Mixin)."""
from pathlib import Path
from typing import Optional, Union, List, Dict, Tuple
import logging
import os
import time
import subprocess
import vdf
logger = logging.getLogger(__name__)
def debug_print(message):
"""Log debug message only if debug mode is enabled"""
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
if config_handler.get('debug_mode', False):
logger.debug(message)
class WorkflowMixin:
"""Mixin providing workflow methods for AutomatedPrefixService."""
def handle_existing_shortcut_conflict(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> Union[bool, List[Dict]]:
"""
Check for existing shortcut with same name and path, prompt user if found.
Args:
shortcut_name: Name of the shortcut to create
exe_path: Path to the executable
modlist_install_dir: Directory where the modlist is installed
Returns:
True if we should proceed (no conflict or user chose to replace), False if user cancelled
"""
try:
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
return True # No shortcuts file, no conflict
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
conflicts = []
# Look for shortcuts with the same name AND path
for i in range(len(shortcuts)):
shortcut = shortcuts[str(i)]
name = shortcut.get('AppName', '')
shortcut_exe = shortcut.get('Exe', '').strip('"') # Remove quotes
shortcut_startdir = shortcut.get('StartDir', '').strip('"') # Remove quotes
# Check if name matches AND (exe path matches OR startdir matches)
# Use exact name match instead of partial match to avoid false positives
name_matches = shortcut_name == name
exe_matches = shortcut_exe == exe_path
startdir_matches = shortcut_startdir == modlist_install_dir
if (name_matches and (exe_matches or startdir_matches)):
conflicts.append({
'index': i,
'name': name,
'exe': shortcut_exe,
'startdir': shortcut_startdir
})
if conflicts:
logger.warning(f"Found {len(conflicts)} existing shortcut(s) with same name and path")
# Log details about each conflict for debugging
for i, conflict in enumerate(conflicts):
logger.info(f"Conflict {i+1}: Name='{conflict['name']}', Exe='{conflict['exe']}', StartDir='{conflict['startdir']}'")
# Return the conflict information so the frontend can handle it
return conflicts
else:
logger.debug("No conflicting shortcuts found")
return True
except Exception as e:
logger.error(f"Error handling shortcut conflict: {e}")
return True # Proceed on error to avoid blocking
def format_conflict_message(self, conflicts: List[Dict]) -> str:
"""
Format conflict information into a user-friendly message.
Args:
conflicts: List of conflict dictionaries from handle_existing_shortcut_conflict
Returns:
Formatted message for the user
"""
if not conflicts:
return "No conflicts found."
message = f"Found {len(conflicts)} existing Steam shortcut(s) with the same name and path:\n\n"
for i, conflict in enumerate(conflicts, 1):
message += f"{i}. **Name:** {conflict['name']}\n"
message += f" **Executable:** {conflict['exe']}\n"
message += f" **Start Directory:** {conflict['startdir']}\n\n"
message += "**Options:**\n"
message += "• **Replace** - Remove the existing shortcut and create a new one\n"
message += "• **Cancel** - Keep the existing shortcut and stop the installation\n"
message += "• **Skip** - Continue without creating a Steam shortcut\n\n"
message += "The existing shortcut will be removed if you choose to replace it."
return message
def run_complete_workflow(self, shortcut_name: str, modlist_install_dir: str,
final_exe_path: str, progress_callback=None) -> Tuple[bool, Optional[Path], Optional[int]]:
"""
Run the simple automated prefix creation workflow.
Args:
shortcut_name: Name for the Steam shortcut
modlist_install_dir: Directory where the modlist is installed
final_exe_path: Path to ModOrganizer.exe
Returns:
Tuple of (success, prefix_path, appid)
"""
debug_print(f"[DEBUG] run_complete_workflow called with shortcut_name={shortcut_name}, modlist_install_dir={modlist_install_dir}, final_exe_path={final_exe_path}")
logger.info("Starting simple automated prefix creation workflow")
# Initialize shared timing to continue from jackify-engine
from jackify.shared.timing import initialize_from_console_output
# TODO: Pass console output if available to continue timeline
initialize_from_console_output()
# Show immediate feedback to user
if progress_callback:
progress_callback("Starting automated Steam setup...")
try:
# Step 1: Create shortcut directly (NO STL needed!)
logger.info("Step 1: Creating shortcut directly to ModOrganizer.exe")
if progress_callback:
progress_callback("Creating Steam shortcut...")
if not self.create_shortcut_directly_with_proton(shortcut_name, final_exe_path, modlist_install_dir):
logger.error("Failed to create shortcut directly")
return False, None, None, None
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam shortcut created successfully")
logger.info("Step 1 completed: Shortcut created directly")
# Step 2: Calculate the predictable AppID and rungameid
logger.info("Step 2: Calculating predictable AppID")
if progress_callback:
progress_callback("Calculating AppID...")
# Calculate AppID using the same method as create_shortcut_directly_with_proton
from zlib import crc32
combined_string = final_exe_path + shortcut_name
crc = crc32(combined_string.encode('utf-8'))
initial_appid = -(crc & 0x7FFFFFFF) # Make it negative and within 32-bit range
# Calculate rungameid for launching
rungameid = (initial_appid << 32) | 0x02000000
# Convert AppID to positive prefix ID
expected_prefix_id = str(abs(initial_appid))
if progress_callback:
progress_callback("AppID calculated")
logger.info(f"Step 2 completed: AppID = {initial_appid}, rungameid = {rungameid}, expected_prefix_id = {expected_prefix_id}")
# Step 3: Restart Steam
logger.info("Step 3: Restarting Steam")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Restarting Steam...")
if not self.restart_steam():
logger.error("Failed to restart Steam")
return False, None, None, None
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam restarted successfully")
logger.info("Step 3 completed: Steam restarted")
# Step 4: Launch temporary batch file to create prefix invisibly
logger.info("Step 4: Launching temporary batch file to create prefix")
debug_print(f"[DEBUG] About to launch temporary batch file with rungameid={rungameid}")
# Launch using rungameid (this will run the batch file invisibly)
try:
result = subprocess.run(['steam', f'steam://rungameid/{rungameid}'],
capture_output=True, text=True, timeout=5)
debug_print(f"[DEBUG] Launch result: return_code={result.returncode}")
if result.returncode != 0:
logger.error(f"Failed to launch temporary batch file: {result.stderr}")
return False, None, None, None
except subprocess.TimeoutExpired:
debug_print("[DEBUG] Launch timed out (expected)")
except Exception as e:
logger.error(f"Error launching temporary batch file: {e}")
return False, None, None, None
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Temporary batch file launched")
logger.info("Step 4 completed: Temporary batch file launched")
# Step 5: Wait for temporary batch file to complete (invisible)
logger.info("Step 5: Waiting for temporary batch file to complete")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Creating Proton prefix (please wait)...")
# Wait for batch file to complete (3 seconds + buffer)
time.sleep(5)
logger.info("Step 5 completed: Temporary batch file completed")
# Step 6: Verify prefix was created
logger.info("Step 6: Verifying prefix creation")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Verifying prefix creation...")
compatdata_path = Path.home() / ".local/share/Steam/steamapps/compatdata" / expected_prefix_id
if not compatdata_path.exists():
logger.error(f"Prefix not found at {compatdata_path}")
return False, None, None, None
logger.info(f"Step 6 completed: Prefix verified at {compatdata_path}")
# Step 7: Replace temporary batch file with final ModOrganizer.exe
logger.info("Step 7: Replacing temporary batch file with final ModOrganizer.exe")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Updating shortcut...")
if not self.replace_shortcut_with_final_exe(shortcut_name, final_exe_path, modlist_install_dir):
logger.error("Failed to replace shortcut with final exe")
return False, None, None, None
logger.info("Step 7 completed: Shortcut updated with final ModOrganizer.exe")
# Step 8: Detect actual AppID using protontricks -l
logger.info("Step 8: Detecting actual AppID")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Detecting actual AppID...")
actual_appid = self.detect_actual_prefix_appid(initial_appid, shortcut_name)
if actual_appid is None:
logger.error("Failed to detect actual AppID")
return False, None, None, None
logger.info(f"Step 8 completed: Actual AppID = {actual_appid}")
# Step 9: Verify prefix was created successfully
logger.info("Step 9: Verifying prefix creation")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Verifying prefix creation...")
prefix_path = self._get_compatdata_path_for_appid(actual_appid)
if not prefix_path or not prefix_path.exists():
logger.error(f"Prefix path not found: {prefix_path}")
return False, None, None, None
if not self.verify_prefix_creation(prefix_path):
logger.error("Prefix verification failed")
return False, None, None, None
logger.info(f"Step 9 completed: Prefix verified at {prefix_path}")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam Configuration complete!")
# Show Proton override notification if applicable
self._show_proton_override_notification(progress_callback)
logger.info(" Simple automated prefix creation workflow completed successfully")
return True, prefix_path, actual_appid
except Exception as e:
logger.error(f"Error in automated prefix creation workflow: {e}")
import traceback
logger.error(f"Full traceback: {traceback.format_exc()}")
return False, None, None, None
def run_working_workflow(self, shortcut_name: str, modlist_install_dir: str,
final_exe_path: str, progress_callback=None, steamdeck: Optional[bool] = None,
download_dir=None, auto_restart: bool = True) -> Tuple[bool, Optional[Path], Optional[int], Optional[str]]:
"""
Run the proven working automated prefix creation workflow.
This implements our tested and working approach:
1. Create shortcut with native Steam service (pointing to ModOrganizer.exe initially)
2. Restart Steam using Jackify's robust method
3. Create Proton prefix invisibly using Proton wrapper with DISPLAY=
4. Verify everything persists
Args:
shortcut_name: Name for the Steam shortcut
modlist_install_dir: Directory where the modlist is installed
final_exe_path: Path to ModOrganizer.exe
progress_callback: Optional callback for progress updates
steamdeck: Optional Steam Deck detection override
download_dir: Optional download path; its mountpoint is added to STEAM_COMPAT_MOUNTS
auto_restart: If True, automatically restart Steam. If False, skip restart step.
Returns:
Tuple of (success, prefix_path, appid, last_timestamp)
"""
logger.info("Starting proven working automated prefix creation workflow")
# Show installation complete and configuration start headers FIRST
if progress_callback:
progress_callback("")
progress_callback("=" * 64)
progress_callback("= Installation phase complete =")
progress_callback("=" * 64)
progress_callback("")
progress_callback("=" * 64)
progress_callback("= Starting Configuration Phase =")
progress_callback("=" * 64)
progress_callback("")
# Reset timing for Steam Integration section (part of Configuration Phase)
from jackify.shared.timing import start_new_phase
start_new_phase()
# Show immediate feedback to user with section header
if progress_callback:
progress_callback("") # Blank line before Steam Integration
progress_callback("=== Steam Integration ===")
progress_callback(f"{self._get_progress_timestamp()} Creating Steam shortcut with native service")
# Registry injection approach for both FNV and Enderal
from ..handlers.modlist_handler import ModlistHandler
modlist_handler = ModlistHandler()
special_game_type = modlist_handler.detect_special_game_type(modlist_install_dir)
# No launch options needed - both FNV and Enderal use registry injection
custom_launch_options = None
if special_game_type in ["fnv", "enderal"]:
logger.info(f"Using registry injection approach for {special_game_type.upper()} modlist")
else:
logger.debug("Standard modlist - no special game handling needed")
try:
# Step 0: Shut down Steam before modifying VDF files
# Required to safely modify shortcuts.vdf and config.vdf without race conditions
logger.info("Step 0: Shutting down Steam before modifying VDF files")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Shutting down Steam...")
from .steam_restart_service import shutdown_steam
try:
if not shutdown_steam():
logger.warning("Steam shutdown returned False, continuing anyway")
except Exception as e:
logger.warning(f"Steam shutdown failed: {e}, continuing anyway")
logger.info("Step 0 completed: Steam shut down")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam shut down")
# Step 1: Create shortcut with native Steam service (Steam is now shut down)
logger.info("Step 1: Creating shortcut with native Steam service")
# DISABLED: Shortcut conflict detection temporarily disabled pending rework
# Re-enable after conflict resolution workflow refactor
# When re-enabled, this will detect and handle cases where shortcuts with the same
# name and path already exist in Steam, allowing users to resolve conflicts
# Disabled pending workflow improvements - planned for future release
# conflict_result = self.handle_existing_shortcut_conflict(shortcut_name, final_exe_path, modlist_install_dir)
# if isinstance(conflict_result, list): # Conflicts found
# logger.warning(f"Found {len(conflict_result)} existing shortcut(s) with same name and path")
# # Return a special tuple to indicate conflict that needs user resolution
# return ("CONFLICT", conflict_result, None)
# elif not conflict_result: # User cancelled or other failure
# logger.error("User cancelled due to shortcut conflict")
# return False, None, None, None
logger.info("Conflict detection temporarily disabled - proceeding with shortcut creation")
# Create shortcut using native Steam service with special game launch options
success, appid = self.create_shortcut_with_native_service(
shortcut_name, final_exe_path, modlist_install_dir, custom_launch_options, download_dir=download_dir
)
if not success:
logger.error("Failed to create shortcut with native Steam service")
return False, None, None, None
logger.info(f"Step 1 completed: Shortcut created with native service, AppID: {appid}")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam shortcut created successfully")
# Apply Steam artwork if available
try:
from ..handlers.modlist_handler import ModlistHandler
modlist_handler = ModlistHandler()
modlist_handler.set_steam_grid_images(str(appid), modlist_install_dir)
logger.info(f"Applied Steam artwork for shortcut '{shortcut_name}' (AppID: {appid})")
except Exception as e:
logger.warning(f"Failed to apply Steam artwork: {e}")
# Step 2: Start Steam (if auto_restart enabled)
logger.info("Step 2: auto_restart=%s", auto_restart)
if auto_restart:
logger.info("Step 2: Starting Steam using Jackify's robust method")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Starting Steam...")
restart_ok = self.restart_steam()
logger.info("Step 2: restart_steam() returned %s", restart_ok)
if not restart_ok:
logger.error("Failed to start Steam")
return False, None, None, None
logger.info("Step 2 completed: Steam started")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam started successfully")
else:
logger.info("Step 2 skipped: Auto-restart disabled by user")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam restart skipped (auto-restart disabled)")
# Step 3: Create Proton prefix invisibly using Proton wrapper
logger.info("Step 3: Creating Proton prefix invisibly")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Creating Proton prefix...")
if not self.create_prefix_with_proton_wrapper(appid):
logger.error("Failed to create Proton prefix")
return False, None, None, None
logger.info("Step 3 completed: Proton prefix created")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Proton prefix created successfully")
# Step 4: Verify everything persists
logger.info("Step 4: Verifying compatibility tool persists")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Verifying setup...")
if not self.verify_compatibility_tool_persists(appid):
logger.warning("Compatibility tool verification failed, but continuing")
logger.info("Step 4 completed: Verification done")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Setup verification completed")
# Step 5: Inject game registry entries for FNV and Enderal modlists
# Get prefix path (needed for logging regardless of game type)
prefix_path = self.get_prefix_path(appid)
if special_game_type in ["fnv", "enderal"]:
logger.info(f"Step 5: Injecting {special_game_type.upper()} game registry entries")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Injecting {special_game_type.upper()} game registry entries...")
if prefix_path:
self._inject_game_registry_entries(str(prefix_path), special_game_type)
else:
logger.warning("Could not find prefix path for registry injection")
else:
logger.info("Step 5: Skipping registry injection for standard modlist")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} No special game registry injection needed")
# Step 5.5: Pre-create game-specific directories for all modlists
logger.info(f"Step 5.5: Creating game-specific user directories")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Creating game user directories...")
if prefix_path:
self._create_game_user_directories(str(prefix_path), special_game_type)
else:
logger.warning("Could not find prefix path for directory creation")
last_timestamp = self._get_progress_timestamp()
logger.info(f" Working workflow completed successfully! AppID: {appid}, Prefix: {prefix_path}")
if progress_callback:
progress_callback(f"{last_timestamp} Steam integration complete")
progress_callback("") # Blank line after Steam integration complete
# Show Proton override notification if applicable
self._show_proton_override_notification(progress_callback)
if progress_callback:
progress_callback("") # Extra blank line to span across Configuration Summary
progress_callback("") # And one more to create space before Prefix Configuration
return True, prefix_path, appid, last_timestamp
except Exception as e:
logger.error(f"Error in working workflow: {e}")
if progress_callback:
progress_callback(f"Error: {str(e)}")
return False, None, None, None
def continue_workflow_after_conflict_resolution(self, shortcut_name: str, modlist_install_dir: str,
final_exe_path: str, appid: int, progress_callback=None) -> Tuple[bool, Optional[Path], Optional[int]]:
"""
Continue the workflow after a shortcut conflict has been resolved.
Args:
shortcut_name: Name of the shortcut
modlist_install_dir: Directory where the modlist is installed
final_exe_path: Path to the final executable
appid: The AppID of the shortcut that was created/replaced
progress_callback: Optional callback for progress updates
Returns:
Tuple of (success, prefix_path, appid)
"""
try:
logger.info("Continuing workflow after conflict resolution")
# Step 2: Restart Steam using Jackify's robust method
logger.info("Step 2: Restarting Steam using Jackify's robust method")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Restarting Steam...")
if not self.restart_steam():
logger.error("Failed to restart Steam")
return False, None, None, None
logger.info("Step 2 completed: Steam restarted")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam restarted successfully")
# Step 3: Create Proton prefix invisibly using Proton wrapper
logger.info("Step 3: Creating Proton prefix invisibly")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Creating Proton prefix...")
if not self.create_prefix_with_proton_wrapper(appid):
logger.error("Failed to create Proton prefix")
return False, None, None, None
logger.info("Step 3 completed: Proton prefix created")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Proton prefix created successfully")
# Step 4: Verify everything persists
logger.info("Step 4: Verifying compatibility tool persists")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Verifying setup...")
if not self.verify_compatibility_tool_persists(appid):
logger.warning("Compatibility tool verification failed, but continuing")
logger.info("Step 4 completed: Verification done")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Setup verification completed")
# Get the prefix path
prefix_path = self.get_prefix_path(appid)
last_timestamp = self._get_progress_timestamp()
logger.info(f" Workflow completed successfully after conflict resolution! AppID: {appid}, Prefix: {prefix_path}")
if progress_callback:
progress_callback(f"{last_timestamp} Automated Steam setup completed successfully!")
return True, prefix_path, appid, last_timestamp
except Exception as e:
logger.error(f"Error continuing workflow after conflict resolution: {e}")
if progress_callback:
progress_callback(f"Error: {str(e)}")
return False, None, None, None

View File

@@ -7,11 +7,14 @@ import json
import subprocess
import time
import threading
import logging
from pathlib import Path
from typing import Optional, List, Dict
from datetime import datetime, timedelta
import urllib.request
logger = logging.getLogger(__name__)
from jackify.backend.models.modlist_metadata import (
ModlistMetadataResponse,
ModlistMetadata,
@@ -120,7 +123,7 @@ class ModlistGalleryService:
# Execute command
# CRITICAL: Use centralized clean environment to prevent AppImage recursive spawning
# This must happen AFTER engine path resolution
# Must happen AFTER engine path resolution
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
clean_env = get_clean_subprocess_env()
@@ -290,7 +293,7 @@ class ModlistGalleryService:
cmd[0] = str(engine_path)
# CRITICAL: Use centralized clean environment to prevent AppImage recursive spawning
# This must happen AFTER engine path resolution
# Must happen AFTER engine path resolution
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
clean_env = get_clean_subprocess_env()
@@ -394,7 +397,7 @@ class ModlistGalleryService:
data = json.loads(response.read().decode('utf-8'))
return data
except Exception as e:
print(f"Warning: Could not load tag mappings: {e}")
logger.warning(f"Could not load tag mappings: {e}")
return {}
def load_allowed_tags(self) -> set:
@@ -410,7 +413,7 @@ class ModlistGalleryService:
data = json.loads(response.read().decode('utf-8'))
return set(data) # Return as set preserving original case
except Exception as e:
print(f"Warning: Could not load allowed tags: {e}")
logger.warning(f"Could not load allowed tags: {e}")
return set()
def _ensure_tag_metadata(self):

View File

@@ -11,10 +11,12 @@ from pathlib import Path
from ..models.modlist import ModlistContext, ModlistInfo
from ..models.configuration import SystemInfo
from .modlist_service_installation import ModlistServiceInstallationMixin
logger = logging.getLogger(__name__)
class ModlistService:
class ModlistService(ModlistServiceInstallationMixin):
"""Service for managing modlist operations."""
def __init__(self, system_info: SystemInfo):
@@ -143,268 +145,7 @@ class ModlistService:
except Exception as e:
logger.error(f"Failed to list modlists: {e}")
raise
def install_modlist(self, context: ModlistContext,
progress_callback=None,
output_callback=None) -> bool:
"""Install a modlist (ONLY installation, no configuration).
This method only runs the engine installation phase.
Configuration must be called separately after Steam setup.
Args:
context: Modlist installation context
progress_callback: Optional callback for progress updates
output_callback: Optional callback for output/logging
Returns:
True if installation successful, False otherwise
"""
logger.info(f"Installing modlist (INSTALLATION ONLY): {context.name}")
try:
# Validate context
if not self._validate_install_context(context):
logger.error("Invalid installation context")
return False
# Prepare directories
fs_handler = self._get_filesystem_handler()
fs_handler.ensure_directory(context.install_dir)
fs_handler.ensure_directory(context.download_dir)
# Use the working ModlistInstallCLI for discovery phase only
from ..core.modlist_operations import ModlistInstallCLI
# Use new SystemInfo pattern
modlist_cli = ModlistInstallCLI(self.system_info)
# Build context for ModlistInstallCLI
install_context = {
'modlist_name': context.name,
'install_dir': context.install_dir,
'download_dir': context.download_dir,
'nexus_api_key': context.nexus_api_key,
'game_type': context.game_type,
'modlist_value': context.modlist_value,
'resolution': getattr(context, 'resolution', None),
'skip_confirmation': True # Service layer should be non-interactive
}
# Set GUI mode for non-interactive operation
import os
original_gui_mode = os.environ.get('JACKIFY_GUI_MODE')
os.environ['JACKIFY_GUI_MODE'] = '1'
try:
# Run discovery phase with pre-filled context
confirmed_context = modlist_cli.run_discovery_phase(context_override=install_context)
if not confirmed_context:
logger.error("Discovery phase failed or was cancelled")
return False
# Now run ONLY the installation part (NOT configuration)
success = self._run_installation_only(
confirmed_context,
progress_callback=progress_callback,
output_callback=output_callback
)
if success:
logger.info("Modlist installation completed successfully (configuration will be done separately)")
return True
else:
logger.error("Modlist installation failed")
return False
finally:
# Restore original GUI mode
if original_gui_mode is not None:
os.environ['JACKIFY_GUI_MODE'] = original_gui_mode
else:
os.environ.pop('JACKIFY_GUI_MODE', None)
except Exception as e:
error_message = str(e)
logger.error(f"Failed to install modlist {context.name}: {error_message}")
# Check for file descriptor limit issues and attempt to handle them
from .resource_manager import handle_file_descriptor_error
try:
if any(indicator in error_message.lower() for indicator in ['too many open files', 'emfile', 'resource temporarily unavailable']):
result = handle_file_descriptor_error(error_message, "modlist installation")
if result['auto_fix_success']:
logger.info(f"File descriptor limit increased automatically. {result['recommendation']}")
elif result['error_detected']:
logger.warning(f"File descriptor limit issue detected but automatic fix failed. {result['recommendation']}")
if result['manual_instructions']:
distro = result['manual_instructions']['distribution']
logger.info(f"Manual ulimit increase instructions available for {distro} distribution")
except Exception as resource_error:
logger.debug(f"Error checking for resource limit issues: {resource_error}")
return False
def _run_installation_only(self, context, progress_callback=None, output_callback=None) -> bool:
"""Run only the installation phase using the engine (COPIED FROM WORKING CODE)."""
import subprocess
import os
import sys
from pathlib import Path
from ..core.modlist_operations import get_jackify_engine_path
try:
# COPIED EXACTLY from working Archive_Do_Not_Write/modules/modlist_install_cli.py
# Process paths (copied from working code)
install_dir_context = context['install_dir']
if isinstance(install_dir_context, tuple):
actual_install_path = Path(install_dir_context[0])
if install_dir_context[1]:
actual_install_path.mkdir(parents=True, exist_ok=True)
else:
actual_install_path = Path(install_dir_context)
install_dir_str = str(actual_install_path)
download_dir_context = context['download_dir']
if isinstance(download_dir_context, tuple):
actual_download_path = Path(download_dir_context[0])
if download_dir_context[1]:
actual_download_path.mkdir(parents=True, exist_ok=True)
else:
actual_download_path = Path(download_dir_context)
download_dir_str = str(actual_download_path)
# CRITICAL: Re-check authentication right before launching engine
# This ensures we use current auth state, not stale cached values from context
# (e.g., if user revoked OAuth after context was created)
from ..services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
current_api_key, current_oauth_info = auth_service.get_auth_for_engine()
# Use current auth state, fallback to context values only if current check failed
api_key = current_api_key or context.get('nexus_api_key')
oauth_info = current_oauth_info or context.get('nexus_oauth_info')
# Path to the engine binary (copied from working code)
engine_path = get_jackify_engine_path()
engine_dir = os.path.dirname(engine_path)
if not os.path.isfile(engine_path) or not os.access(engine_path, os.X_OK):
if output_callback:
output_callback(f"Jackify Install Engine not found or not executable at: {engine_path}")
return False
# Build command (copied from working code)
cmd = [engine_path, 'install', '--show-file-progress']
modlist_value = context.get('modlist_value')
if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value):
cmd += ['-w', modlist_value]
elif modlist_value:
cmd += ['-m', modlist_value]
elif context.get('machineid'):
cmd += ['-m', context['machineid']]
cmd += ['-o', install_dir_str, '-d', download_dir_str]
# NOTE: API key is passed via environment variable only, not as command line argument
# Store original environment values (copied from working code)
original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
}
try:
# Environment setup - prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY
if oauth_info:
os.environ['NEXUS_OAUTH_INFO'] = oauth_info
# CRITICAL: Set client_id so engine can refresh tokens with correct client_id
# Engine's RefreshToken method reads this to use our "jackify" client_id instead of hardcoded "wabbajack"
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
# Also set NEXUS_API_KEY for backward compatibility
if api_key:
os.environ['NEXUS_API_KEY'] = api_key
elif api_key:
os.environ['NEXUS_API_KEY'] = api_key
else:
# No auth available, clear any inherited values
if 'NEXUS_API_KEY' in os.environ:
del os.environ['NEXUS_API_KEY']
if 'NEXUS_OAUTH_INFO' in os.environ:
del os.environ['NEXUS_OAUTH_INFO']
os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1"
pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd])
if output_callback:
output_callback(f"Launching Jackify Install Engine with command: {pretty_cmd}")
# Temporarily increase file descriptor limit for engine process
from jackify.backend.handlers.subprocess_utils import increase_file_descriptor_limit
success, old_limit, new_limit, message = increase_file_descriptor_limit()
if output_callback:
if success:
output_callback(f"File descriptor limit: {message}")
else:
output_callback(f"File descriptor limit warning: {message}")
# Subprocess call with 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)
# Output processing (copied from working code)
buffer = b''
while True:
chunk = proc.stdout.read(1)
if not chunk:
break
buffer += chunk
if chunk == b'\n':
line = buffer.decode('utf-8', errors='replace')
if output_callback:
output_callback(line.rstrip())
buffer = b''
elif chunk == b'\r':
line = buffer.decode('utf-8', errors='replace')
if output_callback:
output_callback(line.rstrip())
buffer = b''
if buffer:
line = buffer.decode('utf-8', errors='replace')
if output_callback:
output_callback(line.rstrip())
proc.wait()
if proc.returncode != 0:
if output_callback:
output_callback(f"Jackify Install Engine exited with code {proc.returncode}.")
return False
else:
if output_callback:
output_callback("Installation completed successfully")
return True
finally:
# Restore environment (copied from working code)
for key, original_value in original_env_values.items():
if original_value is not None:
os.environ[key] = original_value
else:
if key in os.environ:
del os.environ[key]
except Exception as e:
error_msg = f"Error running Jackify Install Engine: {e}"
logger.error(error_msg)
if output_callback:
output_callback(error_msg)
return False
def configure_modlist_post_steam(self, context: ModlistContext,
progress_callback=None,
manual_steps_callback=None,
@@ -503,7 +244,8 @@ class ModlistService:
'skip_confirmation': True, # Service layer should be non-interactive
'manual_steps_completed': True, # Manual steps were done in GUI
'appid': getattr(context, 'app_id', None), # Use updated app_id from Steam
'engine_installed': getattr(context, 'engine_installed', False) # Path manipulation flag
'engine_installed': getattr(context, 'engine_installed', False), # Path manipulation flag
'download_dir': str(context.download_dir) if getattr(context, 'download_dir', None) else None,
}
debug_callback(f"Configuration context built: {config_context}")
@@ -682,7 +424,8 @@ class ModlistService:
'resolution': getattr(context, 'resolution', None),
'skip_confirmation': True, # Service layer should be non-interactive
'manual_steps_completed': False,
'appid': getattr(context, 'app_id', None) # Fix: Include appid like other configuration paths
'appid': getattr(context, 'app_id', None), # Fix: Include appid like other configuration paths
'download_dir': str(context.download_dir) if getattr(context, 'download_dir', None) else None,
}
# DEBUG: Log what resolution we're passing

View File

@@ -0,0 +1,237 @@
"""
Modlist installation phase for ModlistService (Mixin).
Runs engine installation only; configuration is handled separately after Steam setup.
"""
import logging
import os
import subprocess
from pathlib import Path
from ..models.modlist import ModlistContext
logger = logging.getLogger(__name__)
class ModlistServiceInstallationMixin:
"""Mixin providing install_modlist and _run_installation_only for ModlistService."""
def install_modlist(self, context: ModlistContext,
progress_callback=None,
output_callback=None) -> bool:
"""Install a modlist (installation only, no configuration).
Configuration must be called separately after Steam setup.
"""
logger.info(f"Installing modlist (INSTALLATION ONLY): {context.name}")
try:
if not self._validate_install_context(context):
logger.error("Invalid installation context")
return False
fs_handler = self._get_filesystem_handler()
fs_handler.ensure_directory(context.install_dir)
fs_handler.ensure_directory(context.download_dir)
from ..core.modlist_operations import ModlistInstallCLI
modlist_cli = ModlistInstallCLI(self.system_info)
install_context = {
'modlist_name': context.name,
'install_dir': context.install_dir,
'download_dir': context.download_dir,
'nexus_api_key': context.nexus_api_key,
'game_type': context.game_type,
'modlist_value': context.modlist_value,
'resolution': getattr(context, 'resolution', None),
'skip_confirmation': True
}
original_gui_mode = os.environ.get('JACKIFY_GUI_MODE')
os.environ['JACKIFY_GUI_MODE'] = '1'
try:
confirmed_context = modlist_cli.run_discovery_phase(context_override=install_context)
if not confirmed_context:
logger.error("Discovery phase failed or was cancelled")
return False
success = self._run_installation_only(
confirmed_context,
progress_callback=progress_callback,
output_callback=output_callback
)
if success:
logger.info("Modlist installation completed successfully (configuration done separately)")
return True
logger.error("Modlist installation failed")
return False
finally:
if original_gui_mode is not None:
os.environ['JACKIFY_GUI_MODE'] = original_gui_mode
else:
os.environ.pop('JACKIFY_GUI_MODE', None)
except Exception as e:
error_message = str(e)
logger.error(f"Failed to install modlist {context.name}: {error_message}")
from .resource_manager import handle_file_descriptor_error
try:
if any(indicator in error_message.lower() for indicator in
['too many open files', 'emfile', 'resource temporarily unavailable']):
result = handle_file_descriptor_error(error_message, "modlist installation")
if result['auto_fix_success']:
logger.info(f"File descriptor limit increased automatically. {result['recommendation']}")
elif result['error_detected']:
logger.warning(f"File descriptor issue detected but automatic fix failed. {result['recommendation']}")
if result.get('manual_instructions'):
distro = result['manual_instructions']['distribution']
logger.info(f"Manual ulimit increase instructions available for {distro} distribution")
except Exception as resource_error:
logger.debug(f"Error checking for resource limit issues: {resource_error}")
return False
def _run_installation_only(self, context, progress_callback=None, output_callback=None) -> bool:
"""Run only the installation phase using the engine."""
from ..core.modlist_operations import get_jackify_engine_path
try:
install_dir_context = context['install_dir']
if isinstance(install_dir_context, tuple):
actual_install_path = Path(install_dir_context[0])
if install_dir_context[1]:
actual_install_path.mkdir(parents=True, exist_ok=True)
else:
actual_install_path = Path(install_dir_context)
install_dir_str = str(actual_install_path)
download_dir_context = context['download_dir']
if isinstance(download_dir_context, tuple):
actual_download_path = Path(download_dir_context[0])
if download_dir_context[1]:
actual_download_path.mkdir(parents=True, exist_ok=True)
else:
actual_download_path = Path(download_dir_context)
download_dir_str = str(actual_download_path)
from ..services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
current_api_key, current_oauth_info = auth_service.get_auth_for_engine()
api_key = current_api_key or context.get('nexus_api_key')
oauth_info = current_oauth_info or context.get('nexus_oauth_info')
engine_path = get_jackify_engine_path()
engine_dir = os.path.dirname(engine_path)
if not os.path.isfile(engine_path) or not os.access(engine_path, os.X_OK):
if output_callback:
output_callback(f"Jackify Install Engine not found or not executable at: {engine_path}")
return False
cmd = [engine_path, 'install', '--show-file-progress']
modlist_value = context.get('modlist_value')
if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value):
cmd += ['-w', modlist_value]
elif modlist_value:
cmd += ['-m', modlist_value]
elif context.get('machineid'):
cmd += ['-m', context['machineid']]
cmd += ['-o', install_dir_str, '-d', download_dir_str]
original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
}
try:
if oauth_info:
os.environ['NEXUS_OAUTH_INFO'] = oauth_info
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
if api_key:
os.environ['NEXUS_API_KEY'] = api_key
elif api_key:
os.environ['NEXUS_API_KEY'] = api_key
else:
if 'NEXUS_API_KEY' in os.environ:
del os.environ['NEXUS_API_KEY']
if 'NEXUS_OAUTH_INFO' in os.environ:
del os.environ['NEXUS_OAUTH_INFO']
os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1"
pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd])
if output_callback:
output_callback(f"Launching Jackify Install Engine with command: {pretty_cmd}")
from jackify.backend.handlers.subprocess_utils import (
increase_file_descriptor_limit,
get_clean_subprocess_env,
)
success, old_limit, new_limit, message = increase_file_descriptor_limit()
if output_callback:
if success:
output_callback(f"File descriptor limit: {message}")
else:
output_callback(f"File descriptor limit warning: {message}")
clean_env = get_clean_subprocess_env()
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=False, env=clean_env, cwd=engine_dir
)
buffer = b''
while True:
chunk = proc.stdout.read(1)
if not chunk:
break
buffer += chunk
if chunk == b'\n':
line = buffer.decode('utf-8', errors='replace')
if output_callback:
output_callback(line.rstrip())
buffer = b''
elif chunk == b'\r':
line = buffer.decode('utf-8', errors='replace')
if output_callback:
output_callback(line.rstrip())
buffer = b''
if buffer:
line = buffer.decode('utf-8', errors='replace')
if output_callback:
output_callback(line.rstrip())
proc.wait()
if proc.returncode != 0:
if output_callback:
output_callback(f"Jackify Install Engine exited with code {proc.returncode}.")
return False
if output_callback:
output_callback("Installation completed successfully")
return True
finally:
for key, original_value in original_env_values.items():
if original_value is not None:
os.environ[key] = original_value
elif key in os.environ:
del os.environ[key]
except Exception as e:
error_msg = f"Error running Jackify Install Engine: {e}"
logger.error(error_msg)
if output_callback:
output_callback(error_msg)
return False

View File

@@ -451,7 +451,7 @@ class NativeSteamService:
if app_id_exists:
logger.info(f"AppID {app_id} already exists in CompatToolMapping, will be overwritten")
# Remove the existing entry by finding and removing the entire block
# This is complex, so for now just add at the end
# Complex ordering -- just append for now
# Create the new entry in STL's exact format (tabs between key and value)
new_entry = f'\t\t\t\t\t"{app_id}"\n\t\t\t\t\t{{\n\t\t\t\t\t\t"name"\t\t"{proton_version}"\n\t\t\t\t\t\t"config"\t\t""\n\t\t\t\t\t\t"priority"\t\t"250"\n\t\t\t\t\t}}\n'
@@ -574,8 +574,7 @@ class NativeSteamService:
"""
Create symlink to libraryfolders.vdf in Wine prefix for game detection.
This allows Wabbajack running in the prefix to detect Steam games.
Based on Wabbajack-Proton-AuCu implementation.
Allows Wabbajack running in the prefix to detect Steam games.
Args:
app_id: Steam AppID (unsigned)

View File

@@ -259,7 +259,7 @@ class NexusAuthService:
oauth_data = token_data.get('oauth', {})
# Build NexusOAuthState JSON matching upstream Wabbajack format
# This allows engine to auto-refresh tokens during long installations
# Engine auto-refreshes tokens during long installations
nexus_oauth_state = {
"oauth": {
"access_token": oauth_data.get('access_token'),

View File

@@ -184,7 +184,9 @@ class NexusDownloadService:
if file_name_filter:
filtered = [f for f in files if file_name_filter.lower() in f.get('file_name', '').lower()]
if not filtered:
return False, None, f"No files found matching '{file_name_filter}'"
available_files = [f.get('file_name', 'unknown') for f in files]
logger.warning(f"No files matching '{file_name_filter}' in: {available_files}")
return False, None, f"No files found matching '{file_name_filter}'. Available: {', '.join(available_files)}"
files = filtered
# Get the most recent file

View File

@@ -0,0 +1,147 @@
"""
Nexus OAuth callback: _generate_self_signed_cert, _create_callback_handler, _wait_for_callback.
"""
import os
import time
import logging
import tempfile
import urllib.parse
from pathlib import Path
from http.server import BaseHTTPRequestHandler
from typing import Optional, Tuple
logger = logging.getLogger(__name__)
class NexusOAuthCallbackMixin:
"""Mixin providing callback server and wait logic for NexusOAuthService."""
def _generate_self_signed_cert(self) -> Tuple[Optional[str], Optional[str]]:
"""Generate self-signed certificate for HTTPS localhost. Returns (cert_file_path, key_file_path) or (None, None)."""
redirect_host = getattr(self, 'REDIRECT_HOST', '127.0.0.1')
try:
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
import datetime
import ipaddress
logger.info("Generating self-signed certificate for OAuth callback")
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Jackify"),
x509.NameAttribute(NameOID.COMMON_NAME, redirect_host),
])
cert = x509.CertificateBuilder().subject_name(subject).issuer_name(issuer).public_key(
private_key.public_key()
).serial_number(x509.random_serial_number()).not_valid_before(
datetime.datetime.now(datetime.UTC)
).not_valid_after(
datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=365)
).add_extension(
x509.SubjectAlternativeName([x509.IPAddress(ipaddress.IPv4Address(redirect_host))]),
critical=False,
).sign(private_key, hashes.SHA256())
temp_dir = tempfile.mkdtemp()
cert_file = os.path.join(temp_dir, "oauth_cert.pem")
key_file = os.path.join(temp_dir, "oauth_key.pem")
with open(cert_file, "wb") as f:
f.write(cert.public_bytes(serialization.Encoding.PEM))
with open(key_file, "wb") as f:
f.write(private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
))
return cert_file, key_file
except ImportError:
logger.error("cryptography package not installed - required for OAuth")
return None, None
except Exception as e:
logger.error("Failed to generate SSL certificate: %s", e)
return None, None
def _create_callback_handler(self):
"""Create HTTP request handler class for OAuth callback."""
service = self
class OAuthCallbackHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
logger.debug("OAuth callback: %s", format % args)
def do_GET(self):
logger.info("OAuth callback received: %s", self.path)
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
if parsed.path == '/favicon.ico':
self.send_response(404)
self.end_headers()
return
if 'code' in params:
service._auth_code = params['code'][0]
service._auth_state = params.get('state', [None])[0]
logger.info("OAuth authorization code received: %s...", service._auth_code[:10])
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
html = """<html><head><title>Authorization Successful</title></head><body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;"><h1>Authorization Successful!</h1><p>You can close this window and return to Jackify.</p><script>setTimeout(function() { window.close(); }, 3000);</script></body></html>"""
self.wfile.write(html.encode())
elif 'error' in params:
service._auth_error = params['error'][0]
error_desc = params.get('error_description', ['Unknown error'])[0]
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
html = f"<html><head><title>Authorization Failed</title></head><body style='font-family: Arial, sans-serif; text-align: center; padding: 50px;'><h1>Authorization Failed</h1><p>Error: {service._auth_error}</p><p>{error_desc}</p><p>You can close this window and try again in Jackify.</p></body></html>"
self.wfile.write(html.encode())
else:
logger.warning("OAuth callback with no code or error: %s", params)
self.send_response(400)
self.send_header('Content-type', 'text/html')
self.end_headers()
html = "<html><head><title>Invalid Request</title></head><body style='font-family: Arial, sans-serif; text-align: center; padding: 50px;'><h1>Invalid OAuth Callback</h1><p>You can close this window.</p></body></html>"
self.wfile.write(html.encode())
service._server_done.set()
logger.debug("OAuth callback handler signaled server to shut down")
return OAuthCallbackHandler
def _wait_for_callback(self) -> bool:
"""Wait for OAuth callback via jackify:// protocol handler. Returns True if callback received."""
callback_file = Path.home() / ".config" / "jackify" / "oauth_callback.tmp"
if callback_file.exists():
callback_file.unlink()
logger.info("Waiting for OAuth callback via jackify:// protocol")
start_time = time.time()
last_reminder = 0
while (time.time() - start_time) < self.CALLBACK_TIMEOUT:
if callback_file.exists():
try:
lines = callback_file.read_text().strip().split('\n')
if len(lines) >= 2:
self._auth_code = lines[0]
self._auth_state = lines[1]
logger.info("OAuth callback received: code=%s...", self._auth_code[:10])
callback_file.unlink()
return True
except Exception as e:
logger.error("Failed to read callback file: %s", e)
return False
elapsed = time.time() - start_time
if elapsed - last_reminder > 30:
logger.info("Still waiting for OAuth callback... (%ss elapsed)", int(elapsed))
if elapsed > 60:
logger.warning(
"If you see a blank browser tab, check for browser notifications asking to "
"'Open Jackify', or use 'Paste callback URL' in Jackify to paste the URL from the address bar"
)
last_reminder = elapsed
time.sleep(0.5)
logger.error("OAuth callback timeout after %s seconds", self.CALLBACK_TIMEOUT)
logger.error(
"Protocol handler may not be working. Check:\n"
" 1. Browser asked 'Open Jackify?' and you clicked Allow\n"
" 2. No popup blocker notifications\n"
" 3. Desktop file exists: ~/.local/share/applications/com.jackify.app.desktop"
)
return False

View File

@@ -0,0 +1,127 @@
"""
Nexus OAuth protocol handler registration: _ensure_protocol_registered.
"""
import os
import sys
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
class NexusOAuthProtocolMixin:
"""Mixin providing jackify:// protocol registration for NexusOAuthService."""
def _ensure_protocol_registered(self) -> bool:
"""Ensure jackify:// protocol is registered with the OS."""
import subprocess
if not sys.platform.startswith('linux'):
logger.debug("Protocol registration only needed on Linux")
return True
try:
desktop_file = Path.home() / ".local" / "share" / "applications" / "com.jackify.app.desktop"
env = os.environ
is_appimage = (
'APPIMAGE' in env or 'APPDIR' in env or
(sys.argv[0] and sys.argv[0].endswith('.AppImage'))
)
if is_appimage:
if 'APPIMAGE' in env:
exec_path = env['APPIMAGE']
logger.info("Using APPIMAGE env var: %s", exec_path)
elif sys.argv[0] and Path(sys.argv[0]).exists():
exec_path = str(Path(sys.argv[0]).resolve())
logger.info("Using resolved sys.argv[0]: %s", exec_path)
else:
exec_path = sys.argv[0]
logger.warning("Using sys.argv[0] as fallback: %s", exec_path)
else:
src_dir = Path(__file__).resolve().parent.parent.parent.parent
exec_path = f'bash -c \'cd "{src_dir}" && "{sys.executable}" -m jackify.frontends.gui "$@"\' --'
logger.info("DEV mode exec path: %s", exec_path)
logger.info("Source directory: %s", src_dir)
needs_update = False
if not desktop_file.exists():
needs_update = True
logger.info("Creating desktop file for protocol handler")
else:
current_content = desktop_file.read_text()
if is_appimage:
expected_exec = f'Exec="{exec_path}" %u'
else:
expected_exec = f"Exec={exec_path} %u"
if expected_exec not in current_content:
needs_update = True
logger.info("Updating desktop file with new Exec path: %s", exec_path)
if is_appimage and ' ' in exec_path:
import re
if re.search(r'Exec=[^"]\S*\s+\S*\.AppImage', current_content):
needs_update = True
logger.info("Fixing malformed desktop file (unquoted path with spaces)")
if needs_update:
desktop_file.parent.mkdir(parents=True, exist_ok=True)
if is_appimage:
desktop_content = f"""[Desktop Entry]
Type=Application
Name=Jackify
Comment=Wabbajack modlist manager for Linux
Exec="{exec_path}" %u
Icon=com.jackify.app
Terminal=false
Categories=Game;Utility;
MimeType=x-scheme-handler/jackify;
"""
else:
src_dir = Path(__file__).resolve().parent.parent.parent.parent
desktop_content = f"""[Desktop Entry]
Type=Application
Name=Jackify
Comment=Wabbajack modlist manager for Linux
Exec={exec_path} %u
Icon=com.jackify.app
Terminal=false
Categories=Game;Utility;
MimeType=x-scheme-handler/jackify;
Path={src_dir}
"""
desktop_file.write_text(desktop_content)
logger.info("Desktop file written: %s", desktop_file)
logger.info("Exec path: %s", exec_path)
logger.info("AppImage mode: %s", is_appimage)
logger.info("Registering jackify:// protocol handler")
apps_dir = Path.home() / ".local" / "share" / "applications"
subprocess.run(['update-desktop-database', str(apps_dir)], capture_output=True, timeout=10)
subprocess.run(
['xdg-mime', 'default', 'com.jackify.app.desktop', 'x-scheme-handler/jackify'],
capture_output=True, timeout=10
)
subprocess.run(
['xdg-settings', 'set', 'default-url-scheme-handler', 'jackify', 'com.jackify.app.desktop'],
capture_output=True, timeout=10
)
mimeapps_path = Path.home() / ".config" / "mimeapps.list"
try:
if mimeapps_path.exists():
content = mimeapps_path.read_text()
else:
mimeapps_path.parent.mkdir(parents=True, exist_ok=True)
content = "[Default Applications]\n"
if 'x-scheme-handler/jackify=' not in content:
if '[Default Applications]' not in content:
content = "[Default Applications]\n" + content
lines = content.split('\n')
for i, line in enumerate(lines):
if line.strip() == '[Default Applications]':
lines.insert(i + 1, 'x-scheme-handler/jackify=com.jackify.app.desktop')
break
content = '\n'.join(lines)
mimeapps_path.write_text(content)
logger.info("Added jackify handler to mimeapps.list")
except Exception as e:
logger.warning("Failed to update mimeapps.list: %s", e)
logger.info("jackify:// protocol registered successfully")
return True
except Exception as e:
logger.warning("Failed to register jackify:// protocol: %s", e)
return False

View File

@@ -11,21 +11,21 @@ import hashlib
import secrets
import webbrowser
import urllib.parse
from http.server import HTTPServer, BaseHTTPRequestHandler
import requests
import json
import threading
import ssl
import tempfile
import logging
import time
import subprocess
from typing import Optional, Tuple, Dict
from .nexus_oauth_protocol import NexusOAuthProtocolMixin
from .nexus_oauth_callback import NexusOAuthCallbackMixin
logger = logging.getLogger(__name__)
class NexusOAuthService:
class NexusOAuthService(NexusOAuthProtocolMixin, NexusOAuthCallbackMixin):
"""
Handles OAuth 2.0 authentication with Nexus Mods
Uses PKCE flow with system browser and localhost callback
@@ -77,451 +77,35 @@ class NexusOAuthService:
return code_verifier, code_challenge, state
def _ensure_protocol_registered(self) -> bool:
"""
Ensure jackify:// protocol is registered with the OS
Returns:
True if registration successful or already registered
"""
import subprocess
import sys
from pathlib import Path
if not sys.platform.startswith('linux'):
logger.debug("Protocol registration only needed on Linux")
return True
try:
# Ensure desktop file exists and has correct Exec path
desktop_file = Path.home() / ".local" / "share" / "applications" / "com.jackify.app.desktop"
# Get environment for AppImage detection
env = os.environ
# Determine executable path (DEV mode vs AppImage)
# Check multiple indicators for AppImage execution
is_appimage = (
'APPIMAGE' in env or # AppImage environment variable
'APPDIR' in env or # AppImage directory variable
(sys.argv[0] and sys.argv[0].endswith('.AppImage')) # Executable name
)
if is_appimage:
# Running from AppImage - use the AppImage path directly
# CRITICAL: Never use -m flag in AppImage mode - it causes __main__.py windows
if 'APPIMAGE' in env:
# APPIMAGE env var gives us the exact path to the AppImage
exec_path = env['APPIMAGE']
logger.info(f"Using APPIMAGE env var: {exec_path}")
elif sys.argv[0] and Path(sys.argv[0]).exists():
# Use sys.argv[0] if it's a valid path
exec_path = str(Path(sys.argv[0]).resolve())
logger.info(f"Using resolved sys.argv[0]: {exec_path}")
else:
# Fallback to sys.argv[0] as-is
exec_path = sys.argv[0]
logger.warning(f"Using sys.argv[0] as fallback: {exec_path}")
else:
# Running from source (DEV mode)
# Need to ensure we run from the correct directory
src_dir = Path(__file__).parent.parent.parent.parent # Go up to src/
# Use bash -c with proper quoting for paths with spaces
exec_path = f'bash -c \'cd "{src_dir}" && "{sys.executable}" -m jackify.frontends.gui "$@"\' --'
logger.info(f"DEV mode exec path: {exec_path}")
logger.info(f"Source directory: {src_dir}")
# Check if desktop file needs creation or update
needs_update = False
if not desktop_file.exists():
needs_update = True
logger.info("Creating desktop file for protocol handler")
else:
# Check if Exec path matches current mode
current_content = desktop_file.read_text()
# Check for both quoted (AppImage) and unquoted (DEV mode with bash -c) formats
if is_appimage:
expected_exec = f'Exec="{exec_path}" %u'
else:
expected_exec = f"Exec={exec_path} %u"
if expected_exec not in current_content:
needs_update = True
logger.info(f"Updating desktop file with new Exec path: {exec_path}")
# Explicitly detect and fix malformed entries (unquoted paths with spaces)
# Check if any Exec line exists without quotes but contains spaces
if is_appimage and ' ' in exec_path:
import re
# Look for Exec=<path with spaces> without quotes
if re.search(r'Exec=[^"]\S*\s+\S*\.AppImage', current_content):
needs_update = True
logger.info("Fixing malformed desktop file (unquoted path with spaces)")
if needs_update:
desktop_file.parent.mkdir(parents=True, exist_ok=True)
# Build desktop file content with proper working directory
if is_appimage:
# AppImage - quote path to handle spaces
desktop_content = f"""[Desktop Entry]
Type=Application
Name=Jackify
Comment=Wabbajack modlist manager for Linux
Exec="{exec_path}" %u
Icon=com.jackify.app
Terminal=false
Categories=Game;Utility;
MimeType=x-scheme-handler/jackify;
"""
else:
# DEV mode - exec_path already contains bash -c with proper quoting
src_dir = Path(__file__).parent.parent.parent.parent # Go up to src/
desktop_content = f"""[Desktop Entry]
Type=Application
Name=Jackify
Comment=Wabbajack modlist manager for Linux
Exec={exec_path} %u
Icon=com.jackify.app
Terminal=false
Categories=Game;Utility;
MimeType=x-scheme-handler/jackify;
Path={src_dir}
"""
desktop_file.write_text(desktop_content)
logger.info(f"Desktop file written: {desktop_file}")
logger.info(f"Exec path: {exec_path}")
logger.info(f"AppImage mode: {is_appimage}")
# Always ensure full registration (don't trust xdg-settings alone)
# PopOS/Ubuntu need mimeapps.list even if xdg-settings says registered
logger.info("Registering jackify:// protocol handler")
# Update MIME cache (required for Firefox dialog)
apps_dir = Path.home() / ".local" / "share" / "applications"
subprocess.run(
['update-desktop-database', str(apps_dir)],
capture_output=True,
timeout=10
)
# Set as default handler using xdg-mime (Firefox compatibility)
subprocess.run(
['xdg-mime', 'default', 'com.jackify.app.desktop', 'x-scheme-handler/jackify'],
capture_output=True,
timeout=10
)
# Also use xdg-settings as backup (some systems need both)
subprocess.run(
['xdg-settings', 'set', 'default-url-scheme-handler', 'jackify', 'com.jackify.app.desktop'],
capture_output=True,
timeout=10
)
# Manually ensure entry in mimeapps.list (PopOS/Ubuntu require this for GIO)
mimeapps_path = Path.home() / ".config" / "mimeapps.list"
try:
# Read existing content
if mimeapps_path.exists():
content = mimeapps_path.read_text()
else:
mimeapps_path.parent.mkdir(parents=True, exist_ok=True)
content = "[Default Applications]\n"
# Add jackify handler if not present
if 'x-scheme-handler/jackify=' not in content:
if '[Default Applications]' not in content:
content = "[Default Applications]\n" + content
# Insert after [Default Applications] line
lines = content.split('\n')
for i, line in enumerate(lines):
if line.strip() == '[Default Applications]':
lines.insert(i + 1, 'x-scheme-handler/jackify=com.jackify.app.desktop')
break
content = '\n'.join(lines)
mimeapps_path.write_text(content)
logger.info("Added jackify handler to mimeapps.list")
except Exception as e:
logger.warning(f"Failed to update mimeapps.list: {e}")
logger.info("jackify:// protocol registered successfully")
return True
except Exception as e:
logger.warning(f"Failed to register jackify:// protocol: {e}")
return False
def _generate_self_signed_cert(self) -> Tuple[Optional[str], Optional[str]]:
"""
Generate self-signed certificate for HTTPS localhost
Returns:
Tuple of (cert_file_path, key_file_path) or (None, None) on failure
"""
try:
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
import datetime
import ipaddress
logger.info("Generating self-signed certificate for OAuth callback")
# Generate private key
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
# Create certificate
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Jackify"),
x509.NameAttribute(NameOID.COMMON_NAME, self.REDIRECT_HOST),
])
cert = x509.CertificateBuilder().subject_name(
subject
).issuer_name(
issuer
).public_key(
private_key.public_key()
).serial_number(
x509.random_serial_number()
).not_valid_before(
datetime.datetime.now(datetime.UTC)
).not_valid_after(
datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=365)
).add_extension(
x509.SubjectAlternativeName([
x509.IPAddress(ipaddress.IPv4Address(self.REDIRECT_HOST)),
]),
critical=False,
).sign(private_key, hashes.SHA256())
# Save to temp files
temp_dir = tempfile.mkdtemp()
cert_file = os.path.join(temp_dir, "oauth_cert.pem")
key_file = os.path.join(temp_dir, "oauth_key.pem")
with open(cert_file, "wb") as f:
f.write(cert.public_bytes(serialization.Encoding.PEM))
with open(key_file, "wb") as f:
f.write(private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
))
return cert_file, key_file
except ImportError:
logger.error("cryptography package not installed - required for OAuth")
return None, None
except Exception as e:
logger.error(f"Failed to generate SSL certificate: {e}")
return None, None
def _build_authorization_url(self, code_challenge: str, state: str) -> str:
"""
Build OAuth authorization URL
Args:
code_challenge: PKCE code challenge
state: CSRF protection state
Returns:
Authorization URL
Build the Nexus OAuth 2.0 authorisation URL with PKCE parameters.
"""
params = {
'response_type': 'code',
'client_id': self.CLIENT_ID,
'redirect_uri': self.REDIRECT_URI,
'scope': self.SCOPES,
'code_challenge': code_challenge,
'code_challenge_method': 'S256',
'state': state
"response_type": "code",
"client_id": self.CLIENT_ID,
"redirect_uri": self.REDIRECT_URI,
"scope": self.SCOPES,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
query = urllib.parse.urlencode(params)
return f"{self.AUTH_URL}?{query}"
return f"{self.AUTH_URL}?{urllib.parse.urlencode(params)}"
def _create_callback_handler(self):
"""Create HTTP request handler class for OAuth callback"""
service = self
class OAuthCallbackHandler(BaseHTTPRequestHandler):
"""HTTP request handler for OAuth callback"""
def log_message(self, format, *args):
"""Log OAuth callback requests"""
logger.debug(f"OAuth callback: {format % args}")
def do_GET(self):
"""Handle GET request from OAuth redirect"""
logger.info(f"OAuth callback received: {self.path}")
# Parse query parameters
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
# Ignore favicon and other non-OAuth requests
if parsed.path == '/favicon.ico':
self.send_response(404)
self.end_headers()
return
if 'code' in params:
service._auth_code = params['code'][0]
service._auth_state = params.get('state', [None])[0]
logger.info(f"OAuth authorization code received: {service._auth_code[:10]}...")
# Send success response
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
html = """
<html>
<head><title>Authorization Successful</title></head>
<body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
<h1>Authorization Successful!</h1>
<p>You can close this window and return to Jackify.</p>
<script>setTimeout(function() { window.close(); }, 3000);</script>
</body>
</html>
"""
self.wfile.write(html.encode())
elif 'error' in params:
service._auth_error = params['error'][0]
error_desc = params.get('error_description', ['Unknown error'])[0]
# Send error response
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
html = f"""
<html>
<head><title>Authorization Failed</title></head>
<body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
<h1>Authorization Failed</h1>
<p>Error: {service._auth_error}</p>
<p>{error_desc}</p>
<p>You can close this window and try again in Jackify.</p>
</body>
</html>
"""
self.wfile.write(html.encode())
else:
# Unexpected callback format
logger.warning(f"OAuth callback with no code or error: {params}")
self.send_response(400)
self.send_header('Content-type', 'text/html')
self.end_headers()
html = """
<html>
<head><title>Invalid Request</title></head>
<body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
<h1>Invalid OAuth Callback</h1>
<p>You can close this window.</p>
</body>
</html>
"""
self.wfile.write(html.encode())
# Signal server to shut down
service._server_done.set()
logger.debug("OAuth callback handler signaled server to shut down")
return OAuthCallbackHandler
def _wait_for_callback(self) -> bool:
"""
Wait for OAuth callback via jackify:// protocol handler
Returns:
True if callback received, False on timeout
"""
from pathlib import Path
import time
callback_file = Path.home() / ".config" / "jackify" / "oauth_callback.tmp"
# Delete any old callback file
if callback_file.exists():
callback_file.unlink()
logger.info("Waiting for OAuth callback via jackify:// protocol")
# Poll for callback file with periodic user feedback
start_time = time.time()
last_reminder = 0
while (time.time() - start_time) < self.CALLBACK_TIMEOUT:
if callback_file.exists():
try:
# Read callback data
lines = callback_file.read_text().strip().split('\n')
if len(lines) >= 2:
self._auth_code = lines[0]
self._auth_state = lines[1]
logger.info(f"OAuth callback received: code={self._auth_code[:10]}...")
# Clean up
callback_file.unlink()
return True
except Exception as e:
logger.error(f"Failed to read callback file: {e}")
return False
# Show periodic reminder about protocol handler
elapsed = time.time() - start_time
if elapsed - last_reminder > 30: # Every 30 seconds
logger.info(f"Still waiting for OAuth callback... ({int(elapsed)}s elapsed)")
if elapsed > 60:
logger.warning(
"If you see a blank browser tab or popup blocker, "
"check for browser notifications asking to 'Open Jackify'"
)
last_reminder = elapsed
time.sleep(0.5) # Poll every 500ms
logger.error(f"OAuth callback timeout after {self.CALLBACK_TIMEOUT} seconds")
logger.error(
"Protocol handler may not be working. Check:\n"
" 1. Browser asked 'Open Jackify?' and you clicked Allow\n"
" 2. No popup blocker notifications\n"
" 3. Desktop file exists: ~/.local/share/applications/com.jackify.app.desktop"
)
return False
def _send_desktop_notification(self, title: str, message: str):
"""
Send desktop notification if available
Args:
title: Notification title
message: Notification message
"""
def _send_desktop_notification(self, title: str, message: str) -> None:
"""Send a desktop notification via notify-send (Linux). No-op on failure."""
try:
# Try notify-send (Linux)
subprocess.run(
['notify-send', title, message],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=2
["notify-send", title, message],
capture_output=True,
timeout=5,
env={k: v for k, v in os.environ.items() if k not in ("LD_LIBRARY_PATH", "PYTHONPATH", "QT_PLUGIN_PATH")},
)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
logger.debug("Desktop notification skipped: %s", e)
except Exception as e:
logger.debug("Desktop notification failed: %s", e)
def _exchange_code_for_token(
self,
@@ -742,32 +326,35 @@ Path={src_dir}
f"Please open this URL manually:\n{auth_url}"
)
# Wait for callback via jackify:// protocol
if not self._wait_for_callback():
return None
try:
# Wait for callback via jackify:// protocol
if not self._wait_for_callback():
return None
# Check for errors
if self._auth_error:
logger.error(f"Authorization failed: {self._auth_error}")
return None
# Check for errors
if self._auth_error:
logger.error(f"Authorization failed: {self._auth_error}")
return None
if not self._auth_code:
logger.error("No authorization code received")
return None
if not self._auth_code:
logger.error("No authorization code received")
return None
# Verify state matches
if self._auth_state != state:
logger.error("State mismatch - possible CSRF attack")
return None
# Verify state matches
if self._auth_state != state:
logger.error("State mismatch - possible CSRF attack")
return None
logger.info("Authorization code received, exchanging for token")
logger.info("Authorization code received, exchanging for token")
# Exchange code for token
token_data = self._exchange_code_for_token(self._auth_code, code_verifier)
# Exchange code for token
token_data = self._exchange_code_for_token(self._auth_code, code_verifier)
if token_data:
logger.info("OAuth authorization flow completed successfully")
else:
logger.error("Failed to exchange authorization code for token")
if token_data:
logger.info("OAuth authorization flow completed successfully")
else:
logger.error("Failed to exchange authorization code for token")
return token_data
return token_data
finally:
self._expected_oauth_state = None

View File

@@ -133,7 +133,7 @@ class ProtontricksDetectionService:
return False, error_msg
# Install command - use --user flag for user-level installation (works on Steam Deck)
# This avoids requiring system-wide installation permissions
# Avoids system-wide installation permissions
install_cmd = ["flatpak", "install", "--user", "-y", "--noninteractive", "flathub", "com.github.Matoking.protontricks"]
# Use clean environment
@@ -186,7 +186,7 @@ class ProtontricksDetectionService:
elif "network" in stderr_msg.lower() or "connection" in stderr_msg.lower():
error_msg = f"Network error during installation. Check your internet connection.\n\nDetails: {stderr_msg}"
elif "already installed" in stderr_msg.lower():
# This might actually be success - clear cache and re-detect
# Might be success -- clear cache and re-detect
logger.info("Protontricks appears to already be installed (according to flatpak output)")
self._cached_detection_valid = False
return True, "Protontricks is already installed."

View File

@@ -11,7 +11,7 @@ from typing import Callable, Optional
logger = logging.getLogger(__name__)
STRATEGY_JACKIFY = "jackify"
STRATEGY_NAK_SIMPLE = "nak_simple"
STRATEGY_SIMPLE = "simple"
def _get_restart_strategy() -> str:
@@ -20,7 +20,9 @@ def _get_restart_strategy() -> str:
from jackify.backend.handlers.config_handler import ConfigHandler
strategy = ConfigHandler().get("steam_restart_strategy", STRATEGY_JACKIFY)
if strategy not in (STRATEGY_JACKIFY, STRATEGY_NAK_SIMPLE):
if strategy == "nak_simple":
strategy = STRATEGY_SIMPLE
if strategy not in (STRATEGY_JACKIFY, STRATEGY_SIMPLE):
return STRATEGY_JACKIFY
return strategy
except Exception as exc: # pragma: no cover - defensive logging only
@@ -29,8 +31,8 @@ def _get_restart_strategy() -> str:
def _strategy_label(strategy: str) -> str:
if strategy == STRATEGY_NAK_SIMPLE:
return "NaK simple restart"
if strategy == STRATEGY_SIMPLE:
return "Simple restart"
return "Jackify hardened restart"
def _get_clean_subprocess_env():
@@ -137,31 +139,80 @@ def is_steam_deck() -> bool:
logger.debug(f"Error detecting Steam Deck: {e}")
return False
def is_flatpak_steam() -> bool:
"""Detect if Steam is installed as a Flatpak."""
def steam_path_indicates_flatpak(steam_path) -> bool:
"""True if this Steam path is under the Flatpak Steam app dir (user is running Flatpak Steam)."""
if steam_path is None:
return False
path_str = os.fspath(steam_path)
return ".var" in path_str and "app" in path_str and "com.valvesoftware.Steam" in path_str
def _flatpak_steam_data_path_exists() -> bool:
"""True if the Flatpak Steam data directory exists (fallback when resolved_path is None, e.g. AppImage)."""
try:
# First check if flatpak command exists
if not shutil.which('flatpak'):
from pathlib import Path
base = Path.home() / ".var" / "app" / "com.valvesoftware.Steam"
for rel in ("data/Steam", ".local/share/Steam", "home/.local/share/Steam"):
candidate = base / rel
if (candidate / "config" / "loginusers.vdf").exists():
return True
return False
except Exception as e:
logger.debug("Flatpak Steam path check failed: %s", e)
return False
def _get_flatpak_command():
"""Resolve flatpak executable (for detection when PATH is minimal, e.g. AppImage)."""
exe = shutil.which("flatpak")
if exe:
return exe
for p in ("/usr/bin/flatpak", "/usr/local/bin/flatpak"):
if os.path.isfile(p) and os.access(p, os.X_OK):
return p
return None
def is_flatpak_steam() -> bool:
"""Detect if Steam is installed as a Flatpak. Uses flatpak CLI only (no dir heuristic)
so we don't wrongly choose Flatpak when the user has both Flatpak and native Steam."""
try:
flatpak_cmd = _get_flatpak_command()
if not flatpak_cmd:
return False
# Verify the app is actually installed (not just directory exists)
result = subprocess.run(['flatpak', 'list', '--app'],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, # Suppress stderr to avoid error messages
text=True,
timeout=5)
env = _get_clean_subprocess_env()
result = subprocess.run(
[flatpak_cmd, "list", "--app"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
timeout=10,
env=env,
)
if result.returncode == 0:
# Check for exact match - "com.valvesoftware.Steam" as a whole word
# This prevents matching "com.valvesoftware.SteamLink" or similar
for line in result.stdout.splitlines():
parts = line.split()
if parts and parts[0] == 'com.valvesoftware.Steam':
if parts and parts[0] == "com.valvesoftware.Steam":
return True
return False
except Exception as e:
logger.debug(f"Error detecting Flatpak Steam: {e}")
return False
def _get_steam_executable(env=None):
"""Resolve steam executable path for native Steam. Prefer PATH, then common locations."""
env = env or os.environ
path_env = env.get("PATH", "")
exe = shutil.which("steam", path=path_env)
if exe:
return exe
for candidate in ("/usr/games/steam", "/usr/bin/steam"):
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return "steam"
def get_steam_processes() -> list:
"""Return a list of psutil.Process objects for running Steam processes."""
steam_procs = []
@@ -194,53 +245,46 @@ def wait_for_steam_exit(timeout: int = 60, check_interval: float = 0.5) -> bool:
time.sleep(check_interval)
return False
def _start_steam_nak_style(is_steamdeck_flag=False, is_flatpak_flag=False, env_override=None) -> bool:
def _start_steam_simple(is_steamdeck_flag=False, is_flatpak_flag=False, env_override=None) -> bool:
"""
Start Steam using a simplified NaK-style restart (single command, no env cleanup).
CRITICAL: Do NOT use start_new_session - Steam needs to inherit the session
to connect to display/tray. Ensure all GUI environment variables are preserved.
Start Steam using a simplified restart (single command, no env cleanup).
Do NOT use start_new_session - Steam needs to inherit the session for display/tray.
"""
env = env_override if env_override is not None else os.environ.copy()
# Log critical GUI variables for debugging
gui_vars = ['DISPLAY', 'WAYLAND_DISPLAY', 'XDG_SESSION_TYPE', 'DBUS_SESSION_BUS_ADDRESS', 'XDG_RUNTIME_DIR']
for var in gui_vars:
if var in env:
logger.debug(f"NaK-style restart: {var}={env[var][:50] if len(str(env[var])) > 50 else env[var]}")
logger.debug(f"Simple restart: {var}={env[var][:50] if len(str(env[var])) > 50 else env[var]}")
else:
logger.warning(f"NaK-style restart: {var} is NOT SET - Steam GUI may fail!")
logger.warning(f"Simple restart: {var} is NOT SET - Steam GUI may fail!")
try:
if is_steamdeck_flag:
logger.info("NaK-style restart: Steam Deck detected, restarting via systemctl.")
logger.info("Simple restart: Steam Deck detected, restarting via systemctl.")
subprocess.Popen(["systemctl", "--user", "restart", "app-steam@autostart.service"], env=env)
elif is_flatpak_flag:
logger.info("NaK-style restart: Flatpak Steam detected, running flatpak command.")
subprocess.Popen(["flatpak", "run", "com.valvesoftware.Steam"],
env=env, stderr=subprocess.DEVNULL)
logger.info("Simple restart: Flatpak Steam detected, running flatpak command.")
flatpak_cmd = _get_flatpak_command() or "flatpak"
subprocess.Popen([flatpak_cmd, "run", "com.valvesoftware.Steam"],
env=env, stderr=subprocess.DEVNULL)
else:
logger.info("NaK-style restart: launching Steam directly (inheriting session for GUI).")
# NaK uses simple "steam" command without -foreground flag
# Do NOT use start_new_session - Steam needs session access for GUI
# Use shell=True to ensure proper environment inheritance
# This helps with GUI display access on some systems
logger.info("Simple restart: launching Steam directly (inheriting session for GUI).")
subprocess.Popen("steam", shell=True, env=env)
time.sleep(5)
# Use steamwebhelper for detection (actual Steam process, not steam-powerbuttond)
check_result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=env)
if check_result.returncode == 0:
logger.info("NaK-style restart detected running Steam process.")
logger.info("Simple restart detected running Steam process.")
return True
logger.warning("NaK-style restart did not detect Steam process after launch.")
logger.warning("Simple restart did not detect Steam process after launch.")
return False
except FileNotFoundError as exc:
logger.error(f"NaK-style restart command not found: {exc}")
logger.error(f"Simple restart command not found: {exc}")
return False
except Exception as exc:
logger.error(f"NaK-style restart encountered an error: {exc}")
logger.error(f"Simple restart encountered an error: {exc}")
return False
@@ -254,8 +298,8 @@ def start_steam(is_steamdeck_flag=None, is_flatpak_flag=None, env_override=None,
env_override: Optional environment dictionary for subprocess calls
strategy: Restart strategy identifier
"""
if strategy == STRATEGY_NAK_SIMPLE:
return _start_steam_nak_style(
if strategy == STRATEGY_SIMPLE:
return _start_steam_simple(
is_steamdeck_flag=is_steamdeck_flag,
is_flatpak_flag=is_flatpak_flag,
env_override=env_override or os.environ.copy(),
@@ -284,10 +328,10 @@ def start_steam(is_steamdeck_flag=None, is_flatpak_flag=None, env_override=None,
if _is_flatpak:
logger.info("Flatpak Steam detected - trying flatpak run command first")
try:
# Try without flags first (most reliable for Ubuntu/PopOS)
logger.debug("Executing: flatpak run com.valvesoftware.Steam")
subprocess.Popen(["flatpak", "run", "com.valvesoftware.Steam"],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
flatpak_cmd = _get_flatpak_command() or "flatpak"
logger.debug("Executing: %s run com.valvesoftware.Steam", flatpak_cmd)
subprocess.Popen([flatpak_cmd, "run", "com.valvesoftware.Steam"],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(7) # Give Flatpak more time to start
# For Flatpak Steam, check for the flatpak process, not steamwebhelper
check_result = subprocess.run(['pgrep', '-f', 'com.valvesoftware.Steam'], capture_output=True, timeout=10, env=env)
@@ -301,11 +345,11 @@ def start_steam(is_steamdeck_flag=None, is_flatpak_flag=None, env_override=None,
logger.error(f"Flatpak Steam start failed: {e}")
return False # Flatpak Steam must use flatpak command, don't fall back
# Use startup methods with -foreground flag to ensure GUI opens
steam_exe = _get_steam_executable(env)
start_methods = [
{"name": "Popen", "cmd": ["steam", "-foreground"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True, "env": env}},
{"name": "setsid", "cmd": ["setsid", "steam", "-foreground"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "env": env}},
{"name": "nohup", "cmd": ["nohup", "steam", "-foreground"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True, "preexec_fn": os.setpgrp, "env": env}}
{"name": "Popen", "cmd": [steam_exe, "-foreground"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True, "env": env}},
{"name": "setsid", "cmd": ["setsid", steam_exe, "-foreground"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "env": env}},
{"name": "nohup", "cmd": ["nohup", steam_exe, "-foreground"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True, "env": env}}
]
for method in start_methods:
@@ -335,6 +379,106 @@ def start_steam(is_steamdeck_flag=None, is_flatpak_flag=None, env_override=None,
logger.error(f"Error starting Steam: {e}")
return False
def _resolve_steam_path_for_restart():
"""Return the Steam path we're using (for shortcuts/config). Used to decide Flatpak vs native when CLI detection fails."""
try:
from jackify.backend.services.native_steam_service import NativeSteamService
svc = NativeSteamService()
if svc.find_steam_user() and svc.steam_path:
return svc.steam_path
except Exception as e:
logger.debug("Could not resolve Steam path for restart: %s", e)
return None
def shutdown_steam(progress_callback: Optional[Callable[[str], None]] = None, system_info=None) -> bool:
"""
Shut down Steam completely across all distros.
Required before modifying VDF files to prevent race conditions.
Args:
progress_callback: Optional callback for progress updates
system_info: Optional SystemInfo object with pre-detected Steam installation types
Returns:
True if shutdown successful, False otherwise
"""
shutdown_env = _get_clean_subprocess_env()
_is_steam_deck = system_info.is_steamdeck if system_info else is_steam_deck()
resolved_path = _resolve_steam_path_for_restart()
if resolved_path is not None:
_is_flatpak = steam_path_indicates_flatpak(resolved_path)
logger.info("Steam path in use: %s -> flatpak=%s", resolved_path, _is_flatpak)
else:
_is_flatpak = _flatpak_steam_data_path_exists()
if _is_flatpak:
logger.info("Steam path in use: (flatpak data path detected) -> flatpak=True")
else:
_is_flatpak = system_info.is_flatpak_steam if system_info else is_flatpak_steam()
def report(msg):
if progress_callback:
progress_callback(msg)
else:
logger.info(msg)
report("Shutting down Steam...")
# Steam Deck: Use systemctl for shutdown
if _is_steam_deck:
try:
report("Steam Deck detected - using systemctl shutdown...")
subprocess.run(['systemctl', '--user', 'stop', 'app-steam@autostart.service'],
timeout=15, check=False, capture_output=True, env=shutdown_env)
time.sleep(2)
except Exception as e:
logger.debug(f"systemctl stop failed on Steam Deck: {e}")
# Flatpak Steam: Use flatpak kill command
elif _is_flatpak:
try:
report("Flatpak Steam detected - stopping via flatpak...")
flatpak_cmd = _get_flatpak_command() or "flatpak"
subprocess.run([flatpak_cmd, "kill", "com.valvesoftware.Steam"],
timeout=15, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=shutdown_env)
time.sleep(2)
except Exception as e:
logger.debug(f"flatpak kill failed: {e}")
# All systems: Use pkill approach
try:
pkill_result = subprocess.run(['pkill', 'steam'], timeout=15, check=False, capture_output=True, env=shutdown_env)
logger.debug(f"pkill steam result: {pkill_result.returncode}")
time.sleep(2)
# Check if Steam is still running
check_result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=shutdown_env)
if check_result.returncode == 0:
# Force kill if still running
report("Steam still running - force terminating...")
force_result = subprocess.run(['pkill', '-9', 'steam'], timeout=15, check=False, capture_output=True, env=shutdown_env)
logger.debug(f"pkill -9 steam result: {force_result.returncode}")
time.sleep(2)
# Final check
final_check = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=shutdown_env)
if final_check.returncode != 0:
logger.info("Steam processes successfully force terminated.")
else:
logger.warning("Steam processes may still be running after termination attempts.")
report("Steam shutdown incomplete")
return False
else:
logger.info("Steam processes successfully terminated.")
except Exception as e:
logger.warning(f"Error during Steam shutdown: {e}")
report("Steam shutdown had issues")
return False
report("Steam shut down successfully")
return True
def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = None, timeout: int = 60, system_info=None) -> bool:
"""
Robustly restart Steam across all distros. Returns True on success, False on failure.
@@ -350,14 +494,24 @@ def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = No
strategy = _get_restart_strategy()
start_env = shutdown_env if strategy == STRATEGY_JACKIFY else os.environ.copy()
# Use cached detection from system_info if available, otherwise detect
_is_steam_deck = system_info.is_steamdeck if system_info else is_steam_deck()
_is_flatpak = system_info.is_flatpak_steam if system_info else is_flatpak_steam()
resolved_path = _resolve_steam_path_for_restart()
if resolved_path is not None:
_is_flatpak = steam_path_indicates_flatpak(resolved_path)
logger.info("Steam path in use: %s -> flatpak=%s", resolved_path, _is_flatpak)
else:
_is_flatpak = _flatpak_steam_data_path_exists()
if _is_flatpak:
logger.info("Steam path in use: (flatpak data path detected) -> flatpak=True")
else:
_is_flatpak = system_info.is_flatpak_steam if system_info else is_flatpak_steam()
def report(msg):
logger.info(msg)
if progress_callback:
progress_callback(msg)
else:
# Only log directly if no callback (callback chain handles logging)
logger.info(msg)
report("Shutting down Steam...")
report(f"Steam restart strategy: {_strategy_label(strategy)}")
@@ -375,8 +529,9 @@ def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = No
elif _is_flatpak:
try:
report("Flatpak Steam detected - stopping via flatpak...")
subprocess.run(['flatpak', 'kill', 'com.valvesoftware.Steam'],
timeout=15, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=shutdown_env)
flatpak_cmd = _get_flatpak_command() or "flatpak"
subprocess.run([flatpak_cmd, "kill", "com.valvesoftware.Steam"],
timeout=15, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=shutdown_env)
time.sleep(2)
except Exception as e:
logger.debug(f"flatpak kill failed: {e}")

View File

@@ -354,7 +354,7 @@ class UpdateService:
script_content = f'''#!/bin/bash
# Jackify Update Helper Script
# This script safely replaces the current AppImage with the new version
# Safely replaces current AppImage with new version
CURRENT_APPIMAGE="{current_appimage}"
NEW_APPIMAGE="{new_appimage}"

View File

@@ -271,7 +271,7 @@ class VNVPostInstallService:
if not patcher_path:
# Try to download from Nexus
# Note: The Linux version is named "FNV4GB for Proton", not "linux"
# Linux version is named "FNV4GB for Proton", not "linux"
success, patcher_path, msg = self.download_service.download_latest_file(
self.GAME_DOMAIN,
self.LINUX_4GB_PATCHER_MOD_ID,
@@ -410,11 +410,12 @@ class VNVPostInstallService:
logger.info(f"Using cached BSA Decompressor MPI: {mpi_path}")
else:
# Try to download from Nexus
# Look for files with .mpi extension (TTW installer format)
success, mpi_path, msg = self.download_service.download_latest_file(
self.GAME_DOMAIN,
self.FNV_BSA_DECOMPRESSOR_MOD_ID,
self.cache_dir,
file_name_filter="mpi",
file_name_filter=".mpi",
progress_callback=progress_callback
)

View File

@@ -0,0 +1,270 @@
"""
Wabbajack Installer Service
Backend service for orchestrating complete Wabbajack installation workflow.
Handles all 12 steps including Steam shortcuts, prefix creation, and configuration.
"""
import logging
import subprocess
import time
from pathlib import Path
from typing import Optional, Callable, Tuple
from ..handlers.wabbajack_installer_handler import WabbajackInstallerHandler
from ..handlers.config_handler import ConfigHandler
from ..handlers.wine_utils import WineUtils
from .native_steam_service import NativeSteamService
from .steam_restart_service import (
start_steam, is_flatpak_steam, is_steam_deck, _get_clean_subprocess_env, robust_steam_restart
)
from .automated_prefix_service import AutomatedPrefixService
logger = logging.getLogger(__name__)
class WabbajackInstallerService:
"""Service for orchestrating Wabbajack installation workflow"""
def __init__(self):
self.handler = WabbajackInstallerHandler()
self.steam_service = NativeSteamService()
self.config_handler = ConfigHandler()
self.prefix_service = AutomatedPrefixService()
def _resolve_proton_path_and_name(self) -> Tuple[Optional[Path], Optional[str]]:
"""Resolve user's Install Proton path and Steam compat name. Fallback to Proton Experimental."""
user_path = self.config_handler.get_proton_path()
if user_path and user_path != 'auto':
path = Path(user_path).expanduser()
if path.is_dir():
compat_name = WineUtils.resolve_steam_compat_name(path)
if compat_name:
return path, compat_name
dir_name = path.name
if dir_name.startswith('GE-Proton'):
return path, dir_name
steam_name = dir_name.lower().replace(' - ', '_').replace(' ', '_').replace('-', '_')
if not steam_name.startswith('proton'):
steam_name = f"proton_{steam_name}"
return path, steam_name
path = self.handler.find_proton_experimental()
return path, "proton_experimental" if path else None
def install_wabbajack(
self,
install_folder: Path,
shortcut_name: str = "Wabbajack",
enable_gog: bool = True,
progress_callback: Optional[Callable[[str, int], None]] = None,
log_callback: Optional[Callable[[str], None]] = None
) -> Tuple[bool, Optional[int], Optional[str], Optional[int], Optional[str], Optional[str]]:
"""
Execute complete Wabbajack installation workflow.
Args:
install_folder: Directory to install Wabbajack.exe
shortcut_name: Name for Steam shortcut
enable_gog: Whether to detect and inject GOG games
progress_callback: Optional callback(status, percentage)
log_callback: Optional callback for log messages
Returns:
Tuple of (success, app_id, launch_options, gog_count, time_taken_str, error_message)
"""
start_time = time.time()
total_steps = 12
app_id = None
launch_options = ""
gog_count = 0
def update_progress(message: str, step: int, percentage: int = None):
if progress_callback:
if percentage is None:
percentage = int((step / total_steps) * 100)
progress_callback(message, percentage)
if log_callback:
log_callback(message)
else:
# Only log directly if no callback (callback already logs)
logger.info(message)
# Detect Steam installation type once at the start for consistent use throughout
_is_steam_deck = is_steam_deck()
_is_flatpak = is_flatpak_steam()
try:
# Step 1: Check requirements
update_progress("Checking requirements...", 1, 5)
proton_path, proton_compat_name = self._resolve_proton_path_and_name()
if not proton_path:
return False, None, None, None, None, "Proton not found. Install a Proton version in Steam or set Install Proton in Settings."
update_progress(f"Using Proton: {proton_path.name}", 1, 5)
userdata = self.handler.find_steam_userdata_path()
if not userdata:
return False, None, None, None, None, "Steam userdata not found. Please ensure Steam is installed and you're logged in."
update_progress(f"Found Steam userdata: {userdata}", 1, 5)
# Step 2: Download Wabbajack.exe
update_progress("Downloading Wabbajack.exe...", 2, 15)
wabbajack_exe = self.handler.download_wabbajack(install_folder)
if not wabbajack_exe:
return False, None, None, None, None, "Failed to download Wabbajack.exe"
update_progress(f"Downloaded to: {wabbajack_exe}", 2, 15)
# Step 3: Create dotnet cache
update_progress("Creating .NET cache directory...", 3, 20)
self.handler.create_dotnet_cache(install_folder)
update_progress(".NET cache created", 3, 20)
# Step 4: Stop Steam briefly (required to safely modify shortcuts.vdf)
# We'll do a full restart after creating the shortcut
update_progress("Stopping Steam to modify shortcuts...", 4, 25)
try:
shutdown_env = _get_clean_subprocess_env()
if _is_steam_deck:
subprocess.run(['systemctl', '--user', 'stop', 'app-steam@autostart.service'],
timeout=15, check=False, capture_output=True, env=shutdown_env)
elif _is_flatpak:
subprocess.run(['flatpak', 'kill', 'com.valvesoftware.Steam'],
timeout=15, check=False, capture_output=True, env=shutdown_env)
subprocess.run(['pkill', 'steam'], timeout=15, check=False, capture_output=True, env=shutdown_env)
time.sleep(2)
check_result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=shutdown_env)
if check_result.returncode == 0:
subprocess.run(['pkill', '-9', 'steam'], timeout=15, check=False, capture_output=True, env=shutdown_env)
time.sleep(2)
update_progress("Steam stopped", 4, 25)
except Exception as e:
update_progress(f"Warning: Steam shutdown had issues: {e}. Proceeding anyway...", 4, 25)
# Step 5: Create Steam shortcut using NativeSteamService
update_progress("Adding Wabbajack to Steam shortcuts...", 5, 30)
# Generate launch options with STEAM_COMPAT_MOUNTS
launch_options = ""
try:
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
mount_paths = path_handler.get_steam_compat_mount_paths(install_dir=str(install_folder))
if mount_paths:
launch_options = f'STEAM_COMPAT_MOUNTS="{":".join(mount_paths)}" %command%'
update_progress(f"Added STEAM_COMPAT_MOUNTS for Steam libraries: {mount_paths}", 5, 30)
else:
update_progress("No additional Steam libraries found - using empty launch options", 5, 30)
except Exception as e:
update_progress(f"Could not generate STEAM_COMPAT_MOUNTS (non-critical): {e}", 5, 30)
success, app_id = self.steam_service.create_shortcut_with_proton(
app_name=shortcut_name,
exe_path=str(wabbajack_exe),
start_dir=str(wabbajack_exe.parent),
launch_options=launch_options,
tags=["Jackify"],
proton_version=proton_compat_name
)
if not success or app_id is None:
return False, None, None, None, None, "Failed to create Steam shortcut"
update_progress(f"Created Steam shortcut with AppID: {app_id}", 5, 30)
# Step 5b: Restart Steam (same pattern as modlist workflows)
update_progress("Restarting Steam...", 5, 35)
def restart_callback(msg):
update_progress(msg, 5, 35)
if not robust_steam_restart(progress_callback=restart_callback):
update_progress("Warning: Steam restart had issues, continuing anyway...", 5, 35)
else:
update_progress("Steam restarted successfully", 5, 40)
# Step 6: Initialize Wine prefix (using same method as modlist workflows)
update_progress("Creating Proton prefix...", 6, 45)
try:
if self.prefix_service.create_prefix_with_proton_wrapper(app_id):
prefix_path = self.prefix_service.get_prefix_path(app_id)
update_progress(f"Proton prefix created: {prefix_path}", 6, 45)
else:
update_progress("Warning: Prefix creation returned False, continuing anyway...", 6, 45)
except Exception as e:
update_progress(f"Warning: Failed to create prefix: {e}", 6, 45)
update_progress("Continuing anyway...", 6, 45)
# Step 7: Install WebView2
update_progress("Installing WebView2 runtime...", 7, 60)
try:
self.handler.install_webview2(app_id, install_folder, proton_path=proton_path)
update_progress("WebView2 installed successfully", 7, 60)
except Exception as e:
update_progress(f"WARNING: WebView2 installation may have failed: {e}", 7, 60)
update_progress("This may prevent Nexus login in Wabbajack. You can manually install WebView2 later.", 7, 60)
# Step 8: Apply Win7 registry
update_progress("Applying Windows 7 registry settings...", 8, 75)
try:
self.handler.apply_win7_registry(app_id, proton_path=proton_path)
update_progress("Registry settings applied", 8, 75)
except Exception as e:
update_progress(f"Warning: Failed to apply registry settings: {e}", 8, 75)
update_progress("Continuing anyway...", 8, 75)
# Step 9: GOG game detection (optional)
if enable_gog:
update_progress("Detecting GOG games from Heroic...", 9, 80)
try:
gog_count = self.handler.inject_gog_registry(app_id)
if gog_count > 0:
update_progress(f"Detected and injected {gog_count} GOG games", 9, 80)
else:
update_progress("No GOG games found in Heroic", 9, 80)
except Exception as e:
update_progress(f"GOG injection failed (non-critical): {e}", 9, 80)
else:
update_progress("Skipping GOG game detection", 9, 80)
# Step 10: Create Steam library symlinks
update_progress("Creating Steam library symlinks...", 10, 85)
try:
self.steam_service.create_steam_library_symlinks(app_id)
update_progress("Steam library symlinks created", 10, 85)
except Exception as e:
update_progress(f"Warning: Failed to create symlinks: {e}", 10, 85)
# Step 11: Verify Proton compatibility (was set at shortcut creation)
update_progress(f"Proton version: {proton_compat_name}", 11, 90)
# Step 12: Verify Steam is running (was restarted after shortcut creation)
update_progress("Verifying Steam is running...", 12, 95)
check_result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10)
if check_result.returncode == 0:
update_progress("Steam is running", 12, 95)
else:
update_progress("Starting Steam...", 12, 95)
if start_steam(is_steamdeck_flag=_is_steam_deck, is_flatpak_flag=_is_flatpak):
update_progress("Steam started successfully", 12, 95)
time.sleep(3)
else:
update_progress("Warning: Please start Steam manually", 12, 95)
# Calculate time taken
time_taken = int(time.time() - start_time)
mins, secs = divmod(time_taken, 60)
time_str = f"{mins} minutes, {secs} seconds" if mins else f"{secs} seconds"
update_progress("Installation complete!", 12, 100)
update_progress(f"Wabbajack installed to: {install_folder}", 12, 100)
update_progress(f"Steam AppID: {app_id}", 12, 100)
return True, app_id, launch_options, gog_count, time_str, None
except Exception as e:
error_msg = f"Installation failed: {str(e)}"
logger.error(error_msg, exc_info=True)
if log_callback:
log_callback(f"ERROR: {error_msg}")
return False, None, None, None, None, error_msg

View File

@@ -0,0 +1,106 @@
"""
Install Wabbajack Application Command
Provides CLI interface for automated Wabbajack installation
Uses backend service for complete workflow orchestration
"""
import logging
from pathlib import Path
from jackify.backend.services.wabbajack_installer_service import WabbajackInstallerService
from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_SUCCESS, COLOR_ERROR
class InstallWabbajackCommand:
"""CLI command for installing Wabbajack application"""
def __init__(self):
self.logger = logging.getLogger(__name__)
def run(self):
"""Execute Wabbajack installation workflow using backend service"""
print(f"\n{COLOR_INFO}=== Install Wabbajack Application ==={COLOR_RESET}\n")
print("This will download and configure Wabbajack.exe via Proton.")
print("Wabbajack will be added to your Steam library as a non-Steam game.\n")
# Prompt for installation directory
default_dir = str(Path.home() / "Games" / "Wabbajack")
install_dir_input = input(
f"{COLOR_PROMPT}Installation directory [{default_dir}]: {COLOR_RESET}"
).strip()
install_dir = Path(install_dir_input) if install_dir_input else Path(default_dir)
# Prompt for shortcut name
shortcut_name = "Wabbajack"
shortcut_input = input(
f"{COLOR_PROMPT}Shortcut name [{shortcut_name}]: {COLOR_RESET}"
).strip()
if shortcut_input:
shortcut_name = shortcut_input
# Confirm installation with Steam restart warning
print(f"\n{COLOR_INFO}Installation directory: {install_dir}{COLOR_RESET}")
print(f"{COLOR_INFO}Shortcut name: {shortcut_name}{COLOR_RESET}")
print(f"\n{COLOR_PROMPT}{'='*60}{COLOR_RESET}")
print(f"{COLOR_PROMPT}Important: Steam will be restarted during installation.{COLOR_RESET}")
print(f"{COLOR_PROMPT}Please do not manually start or close Steam until installation is complete.{COLOR_RESET}")
print(f"{COLOR_PROMPT}{'='*60}{COLOR_RESET}")
confirm = input(f"\n{COLOR_PROMPT}Proceed with installation? (Y/n): {COLOR_RESET}").strip().lower()
if confirm == 'n':
print("Installation cancelled.")
return
# Execute installation using backend service
print(f"\n{COLOR_INFO}Starting Wabbajack installation...{COLOR_RESET}\n")
service = WabbajackInstallerService()
def progress_callback(message: str, percentage: int):
step_num = int((percentage / 100) * 12) if percentage < 100 else 12
print(f"{COLOR_INFO}[{step_num}/12] {message}{COLOR_RESET}")
def log_callback(message: str):
if "ERROR" in message or "WARNING" in message or "Failed" in message:
print(f"{COLOR_ERROR}{message}{COLOR_RESET}")
elif "successfully" in message.lower() or "created" in message.lower() or "installed" in message.lower():
print(f"{COLOR_SUCCESS}{message}{COLOR_RESET}")
else:
print(f"{COLOR_INFO}{message}{COLOR_RESET}")
success, app_id, launch_options, gog_count, time_taken, error_msg = service.install_wabbajack(
install_folder=install_dir,
shortcut_name=shortcut_name,
enable_gog=True,
progress_callback=progress_callback,
log_callback=log_callback
)
if success:
print(f"\n{COLOR_SUCCESS}{'='*60}{COLOR_RESET}")
print(f"{COLOR_SUCCESS}Wabbajack installation complete!{COLOR_RESET}")
print(f"{COLOR_SUCCESS}{'='*60}{COLOR_RESET}\n")
print(f"{COLOR_INFO}Installation directory: {install_dir}{COLOR_RESET}")
print(f"{COLOR_INFO}Steam AppID: {app_id}{COLOR_RESET}")
if time_taken:
print(f"{COLOR_INFO}Time taken: {time_taken}{COLOR_RESET}")
# Show launch options note (matches GUI)
if launch_options and "STEAM_COMPAT_MOUNTS" in launch_options:
print(f"\n{COLOR_INFO}Note: To access other drives, add paths to launch options (Steam → Properties).{COLOR_RESET}")
print(f"{COLOR_INFO}Append with colons: STEAM_COMPAT_MOUNTS=\"/existing:/new/path\" %command%{COLOR_RESET}")
elif not launch_options:
print(f"\n{COLOR_INFO}Note: To access other drives, add to launch options (Steam → Properties):{COLOR_RESET}")
print(f"{COLOR_INFO}STEAM_COMPAT_MOUNTS=\"/path/to/directory\" %command%{COLOR_RESET}")
print(f"\n{COLOR_INFO}Next steps:{COLOR_RESET}")
print(f" 1. Find '{shortcut_name}' in your Steam library")
print(f" 2. Launch Wabbajack from Steam")
else:
print(f"\n{COLOR_ERROR}Installation failed: {error_msg}{COLOR_RESET}")
print(f"{COLOR_INFO}Check logs for details{COLOR_RESET}")
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")

Some files were not shown because too many files have changed in this diff Show More