mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-07 22:47:45 +02:00
Sync from development - prepare for v0.3.0
This commit is contained in:
@@ -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
546
jackify/backend/core/modlist_operations_configuration_cli.py
Normal file
546
jackify/backend/core/modlist_operations_configuration_cli.py
Normal 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}")
|
||||
170
jackify/backend/core/modlist_operations_configuration_gui.py
Normal file
170
jackify/backend/core/modlist_operations_configuration_gui.py
Normal 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
|
||||
368
jackify/backend/core/modlist_operations_discovery.py
Normal file
368
jackify/backend/core/modlist_operations_discovery.py
Normal 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
|
||||
67
jackify/backend/core/modlist_operations_game_detection.py
Normal file
67
jackify/backend/core/modlist_operations_game_detection.py
Normal 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)
|
||||
99
jackify/backend/core/modlist_operations_nexus.py
Normal file
99
jackify/backend/core/modlist_operations_nexus.py
Normal 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 []
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
108
jackify/backend/handlers/config_handler_directories.py
Normal file
108
jackify/backend/handlers/config_handler_directories.py
Normal 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
|
||||
137
jackify/backend/handlers/config_handler_encryption.py
Normal file
137
jackify/backend/handlers/config_handler_encryption.py
Normal 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
|
||||
76
jackify/backend/handlers/config_handler_proton.py
Normal file
76
jackify/backend/handlers/config_handler_proton.py
Normal 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()
|
||||
@@ -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']}")
|
||||
|
||||
|
||||
@@ -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})")
|
||||
|
||||
|
||||
@@ -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) ...
|
||||
55
jackify/backend/handlers/filesystem_handler_download.py
Normal file
55
jackify/backend/handlers/filesystem_handler_download.py
Normal 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
|
||||
89
jackify/backend/handlers/filesystem_handler_ownership.py
Normal file
89
jackify/backend/handlers/filesystem_handler_ownership.py
Normal 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
|
||||
124
jackify/backend/handlers/filesystem_handler_steam.py
Normal file
124
jackify/backend/handlers/filesystem_handler_steam.py
Normal 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
@@ -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)
|
||||
98
jackify/backend/handlers/menu_handler_input.py
Normal file
98
jackify/backend/handlers/menu_handler_input.py
Normal 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
|
||||
615
jackify/backend/handlers/menu_handler_modlist.py
Normal file
615
jackify/backend/handlers/menu_handler_modlist.py
Normal 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
|
||||
@@ -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
|
||||
584
jackify/backend/handlers/modlist_configuration.py
Normal file
584
jackify/backend/handlers/modlist_configuration.py
Normal 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.")
|
||||
|
||||
376
jackify/backend/handlers/modlist_detection.py
Normal file
376
jackify/backend/handlers/modlist_detection.py
Normal 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
527
jackify/backend/handlers/modlist_install_cli_configuration.py
Normal file
527
jackify/backend/handlers/modlist_install_cli_configuration.py
Normal 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}")
|
||||
|
||||
451
jackify/backend/handlers/modlist_install_cli_discovery.py
Normal file
451
jackify/backend/handlers/modlist_install_cli_discovery.py
Normal 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
|
||||
|
||||
144
jackify/backend/handlers/modlist_install_cli_nexus.py
Normal file
144
jackify/backend/handlers/modlist_install_cli_nexus.py
Normal 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
|
||||
|
||||
180
jackify/backend/handlers/modlist_install_cli_ttw.py
Normal file
180
jackify/backend/handlers/modlist_install_cli_ttw.py
Normal 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}")
|
||||
543
jackify/backend/handlers/modlist_wine_ops.py
Normal file
543
jackify/backend/handlers/modlist_wine_ops.py
Normal 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
149
jackify/backend/handlers/path_handler_dxvk.py
Normal file
149
jackify/backend/handlers/path_handler_dxvk.py
Normal 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
|
||||
184
jackify/backend/handlers/path_handler_game.py
Normal file
184
jackify/backend/handlers/path_handler_game.py
Normal 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
|
||||
492
jackify/backend/handlers/path_handler_mo2.py
Normal file
492
jackify/backend/handlers/path_handler_mo2.py
Normal 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
|
||||
226
jackify/backend/handlers/path_handler_steam.py
Normal file
226
jackify/backend/handlers/path_handler_steam.py
Normal 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
143
jackify/backend/handlers/progress_parser_extraction.py
Normal file
143
jackify/backend/handlers/progress_parser_extraction.py
Normal 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))
|
||||
235
jackify/backend/handlers/progress_parser_files.py
Normal file
235
jackify/backend/handlers/progress_parser_files.py
Normal 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
|
||||
96
jackify/backend/handlers/progress_parser_phase.py
Normal file
96
jackify/backend/handlers/progress_parser_phase.py
Normal 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)
|
||||
167
jackify/backend/handlers/progress_state_metrics.py
Normal file
167
jackify/backend/handlers/progress_state_metrics.py
Normal 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
|
||||
239
jackify/backend/handlers/progress_state_processing.py
Normal file
239
jackify/backend/handlers/progress_state_processing.py
Normal 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
|
||||
147
jackify/backend/handlers/protontricks_commands.py
Normal file
147
jackify/backend/handlers/protontricks_commands.py
Normal 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
|
||||
195
jackify/backend/handlers/protontricks_detection.py
Normal file
195
jackify/backend/handlers/protontricks_detection.py
Normal 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
271
jackify/backend/handlers/protontricks_prefix.py
Normal file
271
jackify/backend/handlers/protontricks_prefix.py
Normal 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}")
|
||||
267
jackify/backend/handlers/protontricks_steam.py
Normal file
267
jackify/backend/handlers/protontricks_steam.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
156
jackify/backend/handlers/shortcut_creation.py
Normal file
156
jackify/backend/handlers/shortcut_creation.py
Normal 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
|
||||
340
jackify/backend/handlers/shortcut_discovery.py
Normal file
340
jackify/backend/handlers/shortcut_discovery.py
Normal 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
162
jackify/backend/handlers/shortcut_launch_options.py
Normal file
162
jackify/backend/handlers/shortcut_launch_options.py
Normal 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}")
|
||||
293
jackify/backend/handlers/shortcut_steam_restart.py
Normal file
293
jackify/backend/handlers/shortcut_steam_restart.py
Normal 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
|
||||
318
jackify/backend/handlers/shortcut_vdf_management.py
Normal file
318
jackify/backend/handlers/shortcut_vdf_management.py
Normal 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")
|
||||
@@ -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:
|
||||
|
||||
318
jackify/backend/handlers/ttw_installer_backend.py
Normal file
318
jackify/backend/handlers/ttw_installer_backend.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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:
|
||||
|
||||
296
jackify/backend/handlers/wabbajack_directory.py
Normal file
296
jackify/backend/handlers/wabbajack_directory.py
Normal 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
@@ -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]:
|
||||
"""
|
||||
|
||||
347
jackify/backend/handlers/wabbajack_prefix_setup.py
Normal file
347
jackify/backend/handlers/wabbajack_prefix_setup.py
Normal 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
|
||||
148
jackify/backend/handlers/wabbajack_steam_integration.py
Normal file
148
jackify/backend/handlers/wabbajack_steam_integration.py
Normal 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
|
||||
151
jackify/backend/handlers/wabbajack_verification.py
Normal file
151
jackify/backend/handlers/wabbajack_verification.py
Normal 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
|
||||
140
jackify/backend/handlers/wabbajack_webview.py
Normal file
140
jackify/backend/handlers/wabbajack_webview.py
Normal 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
117
jackify/backend/handlers/wine_utils_config.py
Normal file
117
jackify/backend/handlers/wine_utils_config.py
Normal 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
|
||||
482
jackify/backend/handlers/wine_utils_proton.py
Normal file
482
jackify/backend/handlers/wine_utils_proton.py
Normal 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
|
||||
140
jackify/backend/handlers/wine_wrapper.py
Normal file
140
jackify/backend/handlers/wine_wrapper.py
Normal 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
|
||||
84
jackify/backend/handlers/winetricks_discovery.py
Normal file
84
jackify/backend/handlers/winetricks_discovery.py
Normal 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
|
||||
301
jackify/backend/handlers/winetricks_env.py
Normal file
301
jackify/backend/handlers/winetricks_env.py
Normal 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
262
jackify/backend/handlers/winetricks_installation.py
Normal file
262
jackify/backend/handlers/winetricks_installation.py
Normal 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
|
||||
43
jackify/backend/handlers/winetricks_verification.py
Normal file
43
jackify/backend/handlers/winetricks_verification.py
Normal 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
|
||||
500
jackify/backend/services/automated_prefix_creation.py
Normal file
500
jackify/backend/services/automated_prefix_creation.py
Normal 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
|
||||
|
||||
272
jackify/backend/services/automated_prefix_game_utils.py
Normal file
272
jackify/backend/services/automated_prefix_game_utils.py
Normal 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}")
|
||||
|
||||
673
jackify/backend/services/automated_prefix_proton.py
Normal file
673
jackify/backend/services/automated_prefix_proton.py
Normal 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
|
||||
|
||||
276
jackify/backend/services/automated_prefix_registry.py
Normal file
276
jackify/backend/services/automated_prefix_registry.py
Normal 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
534
jackify/backend/services/automated_prefix_shortcuts.py
Normal file
534
jackify/backend/services/automated_prefix_shortcuts.py
Normal 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
|
||||
|
||||
138
jackify/backend/services/automated_prefix_shortcuts_cleanup.py
Normal file
138
jackify/backend/services/automated_prefix_shortcuts_cleanup.py
Normal 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
|
||||
190
jackify/backend/services/automated_prefix_stl.py
Normal file
190
jackify/backend/services/automated_prefix_stl.py
Normal 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
|
||||
|
||||
556
jackify/backend/services/automated_prefix_workflow.py
Normal file
556
jackify/backend/services/automated_prefix_workflow.py
Normal 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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
237
jackify/backend/services/modlist_service_installation.py
Normal file
237
jackify/backend/services/modlist_service_installation.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
147
jackify/backend/services/nexus_oauth_callback.py
Normal file
147
jackify/backend/services/nexus_oauth_callback.py
Normal 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
|
||||
127
jackify/backend/services/nexus_oauth_protocol.py
Normal file
127
jackify/backend/services/nexus_oauth_protocol.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
270
jackify/backend/services/wabbajack_installer_service.py
Normal file
270
jackify/backend/services/wabbajack_installer_service.py
Normal 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
|
||||
|
||||
106
jackify/frontends/cli/commands/install_wabbajack.py
Normal file
106
jackify/frontends/cli/commands/install_wabbajack.py
Normal 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
Reference in New Issue
Block a user