mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 04:07:45 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12294d3186 | ||
|
|
b55e1cf768 | ||
|
|
8e49602714 | ||
|
|
98a9a4c7c6 | ||
|
|
286d51e6a1 |
46
CHANGELOG.md
46
CHANGELOG.md
@@ -1,5 +1,51 @@
|
||||
# Jackify Changelog
|
||||
|
||||
## v0.3.0 - Codebase Refactoring
|
||||
**Release Date:** 2026-02-06
|
||||
|
||||
### Technical Improvements
|
||||
- **Code Architecture**: Refactored 13 large files (1000-5000 lines each) into 50+ focused modules using mixin pattern. All main files now under 600 lines.
|
||||
|
||||
### Bug Fixes
|
||||
- **Configure New Modlist GUI**: Fixed window not shrinking when Show Details unchecked
|
||||
- **CLI Wabbajack Installer**: Added missing installation command to CLI menu
|
||||
- **Wabbajack Installer**: Fixed installation to non-primary disk
|
||||
|
||||
### Improvements
|
||||
- **Wabbajack Install - Honour Install Proton**: Wabbajack installer now uses the user's selected Install Proton from Settings (same as modlist install/configure). Previously hardcoded to Proton Experimental. Fallback to Proton Experimental when no selection or path invalid.
|
||||
- **STEAM_COMPAT_MOUNTS (Issue #155)**: Launch options now include mountpoints for both the modlist install path and the download path when known, so MO2 can access game and downloads on different drives. Uses new mountpoint helper and passes install_dir/download_dir through the Install a Modlist workflow.
|
||||
- **MO2 download_directory (Issue #154)**: When configuring after Install a Modlist, Jackify now sets `download_directory` in ModOrganizer.ini to the correct Wine path (Z: or D: on SD card) so MO2 finds the download folder. Configure New and Configure Existing continue to leave or blank the key as before.
|
||||
- **Winetricks / Protontricks**: For Flatpak Steam, use protontricks only. Winetricks alone struggles with the flatpak sandbox.
|
||||
- **Wine Component Animation**: Added pulser animation for individual wine component installation progress in Configure Existing and Install Modlist workflows
|
||||
- **Wabbajack Installer Log Rotation**: Added log rotation for Wabbajack installer workflow logs
|
||||
|
||||
---
|
||||
|
||||
## v0.2.2.2 - ModOrganizer.ini Path Fixes for SD Card Installations
|
||||
**Release Date:** 2026-01-28
|
||||
|
||||
### Bug Fixes
|
||||
- **ModOrganizer.ini Path Mangling**: Fixed incorrect drive letter assignment when modlist is on SD card but vanilla game is on internal storage. Now uses gamePath drive letter as source of truth for vanilla game paths.
|
||||
- **Proton Config Name Mismatch (Issues #150, #151)**: Fixed incorrect Proton names written to Steam config.vdf CompatToolMapping. Naive string conversion produced wrong names (e.g., `proton_9.0_(beta)` instead of `proton_9`). Now resolves correct internal names from `compatibilitytool.vdf` (third-party) or App ID mapping (Valve Proton). CachyOS and other community Proton builds in `compatibilitytools.d/` are now detected and selectable.
|
||||
- **Removed Lorerim/Lost Legacy Proton Override**: No longer forces Proton 9 for specific modlists. ENB compatibility warnings are handled by the success dialog instead.
|
||||
|
||||
### Engine Updates
|
||||
- **jackify-engine 0.4.7**: Fixed incorrect quoting/escaping of MO2 `customExecutables` by writing clean, unquoted Proton `Z:\...` paths in `ModOrganizer.ini`. This eliminates engine-side quote corruption that previously triggered SD card path mangling issues.
|
||||
|
||||
### Improvements
|
||||
- **Improved Wine Component install debug log output**: Will now print the full command being used for winetricks and protontricks when debug mode is enabled, making it easier to reproduce issues manually.
|
||||
|
||||
---
|
||||
|
||||
## v0.2.2.1 - TTW Installer Pinning and Configure New Modlist CLI Fix
|
||||
**Release Date:** 2026-01-24
|
||||
|
||||
### Bug Fixes
|
||||
- **Configure New Modlist CLI**: Fixed manual Proton setup prompts appearing in CLI. Now uses automated prefix workflow like the install command.
|
||||
- **TTW_Linux_Installer Version Pinning**: Pinned to v0.0.7. Will re-introduce latest version following more testing.
|
||||
|
||||
---
|
||||
|
||||
## v0.2.2 - VNV Automation and First-Launch Improvements
|
||||
**Release Date:** 2026-01-21
|
||||
|
||||
|
||||
@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
|
||||
Wabbajack modlists natively on Linux systems.
|
||||
"""
|
||||
|
||||
__version__ = "0.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):
|
||||
@@ -307,222 +308,6 @@ class ConfigHandler:
|
||||
"""Get the path to protontricks executable"""
|
||||
return self.settings.get("protontricks_path")
|
||||
|
||||
def _get_encryption_key(self) -> bytes:
|
||||
"""
|
||||
Generate encryption key for API key storage using same method as OAuth tokens
|
||||
|
||||
Returns:
|
||||
Fernet-compatible encryption key
|
||||
"""
|
||||
import socket
|
||||
import getpass
|
||||
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
username = getpass.getuser()
|
||||
|
||||
# Try to get machine ID
|
||||
machine_id = None
|
||||
try:
|
||||
with open('/etc/machine-id', 'r') as f:
|
||||
machine_id = f.read().strip()
|
||||
except:
|
||||
try:
|
||||
with open('/var/lib/dbus/machine-id', 'r') as f:
|
||||
machine_id = f.read().strip()
|
||||
except:
|
||||
pass
|
||||
|
||||
if machine_id:
|
||||
key_material = f"{hostname}:{username}:{machine_id}:jackify"
|
||||
else:
|
||||
key_material = f"{hostname}:{username}:jackify"
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get machine info for encryption: {e}")
|
||||
key_material = "jackify:default:key"
|
||||
|
||||
# Generate Fernet-compatible key
|
||||
key_bytes = hashlib.sha256(key_material.encode('utf-8')).digest()
|
||||
return base64.urlsafe_b64encode(key_bytes)
|
||||
|
||||
def _encrypt_api_key(self, api_key: str) -> str:
|
||||
"""
|
||||
Encrypt API key using AES-GCM
|
||||
|
||||
Args:
|
||||
api_key: Plain text API key
|
||||
|
||||
Returns:
|
||||
Encrypted API key string
|
||||
"""
|
||||
try:
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Random import get_random_bytes
|
||||
|
||||
# Derive 32-byte AES key
|
||||
key = base64.urlsafe_b64decode(self._get_encryption_key())
|
||||
|
||||
# Generate random nonce
|
||||
nonce = get_random_bytes(12)
|
||||
|
||||
# Encrypt with AES-GCM
|
||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
||||
ciphertext, tag = cipher.encrypt_and_digest(api_key.encode('utf-8'))
|
||||
|
||||
# Combine and encode
|
||||
combined = nonce + ciphertext + tag
|
||||
return base64.b64encode(combined).decode('utf-8')
|
||||
|
||||
except ImportError:
|
||||
# Fallback to base64 if pycryptodome not available
|
||||
logger.warning("pycryptodome not available, using base64 encoding (less secure)")
|
||||
return base64.b64encode(api_key.encode('utf-8')).decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.error(f"Error encrypting API key: {e}")
|
||||
return ""
|
||||
|
||||
def _decrypt_api_key(self, encrypted_key: str) -> Optional[str]:
|
||||
"""
|
||||
Decrypt API key using AES-GCM
|
||||
|
||||
Args:
|
||||
encrypted_key: Encrypted API key string
|
||||
|
||||
Returns:
|
||||
Decrypted API key or None on failure
|
||||
"""
|
||||
try:
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
# 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,690 +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)}")
|
||||
# --- Create shortcut with working NativeSteamService ---
|
||||
try:
|
||||
from ..services.native_steam_service import NativeSteamService
|
||||
steam_service = NativeSteamService()
|
||||
|
||||
success, app_id = steam_service.create_shortcut_with_proton(
|
||||
app_name=modlist_name,
|
||||
exe_path=mo2_path,
|
||||
start_dir=os.path.dirname(mo2_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"\n{COLOR_ERROR}Failed to create Steam shortcut. Check the logs for details.{COLOR_RESET}")
|
||||
return True
|
||||
mo2_dir = os.path.dirname(mo2_path)
|
||||
if os.environ.get('JACKIFY_GUI_MODE'):
|
||||
print('[PROMPT:RESTART_STEAM]')
|
||||
input() # Wait for GUI to send confirmation
|
||||
print('[PROMPT:MANUAL_STEPS]')
|
||||
input() # Wait for GUI to send confirmation
|
||||
# Continue as before
|
||||
else:
|
||||
print("\n───────────────────────────────────────────────────────────────────")
|
||||
print(f"{COLOR_INFO}Important:{COLOR_RESET} Steam needs to restart to detect the new shortcut.")
|
||||
print("This process involves several manual steps after the restart.")
|
||||
restart_choice = input("\nRestart Steam automatically now? (Y/n): ").strip().lower()
|
||||
if restart_choice == 'n':
|
||||
self.logger.info("User opted out of automatic Steam restart.")
|
||||
print("\nPlease restart Steam manually to see your new shortcut:")
|
||||
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(modlist_name)
|
||||
print(f"\n{COLOR_ERROR}You will need to re-run this configuration option after completing these steps.{COLOR_RESET}")
|
||||
print("───────────────────────────────────────────────────────────────────")
|
||||
return True
|
||||
self.logger.info("Attempting secure Steam restart...")
|
||||
print()
|
||||
status_line = ""
|
||||
def update_status(msg):
|
||||
nonlocal status_line
|
||||
if status_line:
|
||||
print("\r" + " " * len(status_line), end="\r")
|
||||
status_line = f"\r{COLOR_INFO}{msg}{COLOR_RESET}"
|
||||
print(status_line, end="", flush=True)
|
||||
# Actually restart Steam and wait for completion
|
||||
if self.shortcut_handler.secure_steam_restart(status_callback=update_status):
|
||||
print()
|
||||
self.logger.info("Secure Steam restart successful.")
|
||||
self._display_manual_proton_steps(modlist_name)
|
||||
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.")
|
||||
# Re-detect the shortcut and get the new, positive AppID
|
||||
new_app_id = self.shortcut_handler.get_appid_for_shortcut(modlist_name, mo2_path)
|
||||
self.logger.info(f"Pre-launch AppID: {app_id}, Post-launch AppID: {new_app_id}")
|
||||
if not new_app_id or not new_app_id.isdigit() or int(new_app_id) < 0:
|
||||
print(f"{COLOR_ERROR}Could not find a valid AppID for '{modlist_name}' after launch. Please ensure you launched the shortcut from Steam at least once, then try again.{COLOR_RESET}")
|
||||
return True
|
||||
context = {
|
||||
"name": modlist_name,
|
||||
"appid": new_app_id,
|
||||
"path": mo2_dir,
|
||||
"manual_steps_completed": True,
|
||||
"resolution": None
|
||||
}
|
||||
self.logger.debug(f"[DEBUG] New Modlist Context (post-launch): {context}")
|
||||
return self.run_modlist_configuration_phase(context)
|
||||
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:
|
||||
"""
|
||||
@@ -995,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:
|
||||
@@ -1062,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
|
||||
@@ -1072,65 +396,5 @@ class MenuHandler:
|
||||
print("")
|
||||
return None
|
||||
finally:
|
||||
if READLINE_AVAILABLE:
|
||||
if READLINE_AVAILABLE and readline:
|
||||
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
|
||||
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__)
|
||||
|
||||
@@ -31,9 +31,12 @@ TTW_INSTALLER_EXECUTABLE_NAME = "ttw_linux_gui" # Same executable, runs in CLI
|
||||
# GitHub release info
|
||||
TTW_INSTALLER_REPO = "SulfurNitride/TTW_Linux_Installer"
|
||||
TTW_INSTALLER_RELEASE_URL = f"https://api.github.com/repos/{TTW_INSTALLER_REPO}/releases/latest"
|
||||
# Pin to 0.0.7 - last version with old format (ttw_linux_gui, universal-mpi-installer)
|
||||
# Set to None to use latest release
|
||||
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,
|
||||
@@ -70,18 +73,26 @@ class TTWInstallerHandler:
|
||||
self.ttw_installer_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _check_installation(self):
|
||||
"""Check if TTW_Linux_Installer is installed at expected location."""
|
||||
"""Check if TTW_Linux_Installer is installed at expected location.
|
||||
|
||||
Checks for both old format (ttw_linux_gui) and new format (mpi_installer) executables.
|
||||
"""
|
||||
self._ensure_dirs_exist()
|
||||
|
||||
potential_exe_path = self.ttw_installer_dir / TTW_INSTALLER_EXECUTABLE_NAME
|
||||
# Check for both old (ttw_linux_gui) and new (mpi_installer) executable names
|
||||
exe_names = [TTW_INSTALLER_EXECUTABLE_NAME, "mpi_installer"]
|
||||
for exe_name in exe_names:
|
||||
potential_exe_path = self.ttw_installer_dir / exe_name
|
||||
if potential_exe_path.is_file() and os.access(potential_exe_path, os.X_OK):
|
||||
self.ttw_installer_executable_path = potential_exe_path
|
||||
self.ttw_installer_installed = True
|
||||
self.logger.info(f"Found TTW_Linux_Installer at: {self.ttw_installer_executable_path}")
|
||||
else:
|
||||
return
|
||||
|
||||
# Not found
|
||||
self.ttw_installer_installed = False
|
||||
self.ttw_installer_executable_path = None
|
||||
self.logger.info(f"TTW_Linux_Installer not found at {potential_exe_path}")
|
||||
self.logger.info(f"TTW_Linux_Installer not found (searched for: {', '.join(exe_names)})")
|
||||
|
||||
def install_ttw_installer(self, install_dir: Optional[Path] = None) -> Tuple[bool, str]:
|
||||
"""Download and install TTW_Linux_Installer from GitHub releases.
|
||||
@@ -97,9 +108,34 @@ class TTWInstallerHandler:
|
||||
target_dir = Path(install_dir) if install_dir else self.ttw_installer_dir
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Fetch latest release info
|
||||
self.logger.info(f"Fetching latest TTW_Linux_Installer release from {TTW_INSTALLER_RELEASE_URL}")
|
||||
resp = requests.get(TTW_INSTALLER_RELEASE_URL, timeout=15, verify=True)
|
||||
# Fetch release info - always use pinned version when set; never use latest
|
||||
if TTW_INSTALLER_PINNED_VERSION:
|
||||
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")
|
||||
@@ -118,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
|
||||
@@ -151,17 +187,39 @@ class TTWInstallerHandler:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Find executable (may be in subdirectory or root)
|
||||
exe_path = target_dir / TTW_INSTALLER_EXECUTABLE_NAME
|
||||
if not exe_path.is_file():
|
||||
# Search for it
|
||||
for p in target_dir.rglob(TTW_INSTALLER_EXECUTABLE_NAME):
|
||||
# Find executable - support both old (ttw_linux_gui) and new (mpi_installer) names
|
||||
# Try old name first (since we're pinning to 0.0.7)
|
||||
exe_names = [TTW_INSTALLER_EXECUTABLE_NAME, "mpi_installer"]
|
||||
exe_path = None
|
||||
|
||||
for exe_name in exe_names:
|
||||
potential_path = target_dir / exe_name
|
||||
if potential_path.is_file():
|
||||
exe_path = potential_path
|
||||
self.logger.info(f"Found executable: {exe_name}")
|
||||
break
|
||||
# Search recursively
|
||||
for p in target_dir.rglob(exe_name):
|
||||
if p.is_file():
|
||||
exe_path = p
|
||||
self.logger.info(f"Found executable: {exe_name} at {p}")
|
||||
break
|
||||
if exe_path:
|
||||
break
|
||||
|
||||
if not exe_path.is_file():
|
||||
return False, "TTW_Linux_Installer executable not found after extraction"
|
||||
if not exe_path or not exe_path.is_file():
|
||||
return False, f"TTW_Linux_Installer executable not found after extraction (searched for: {', '.join(exe_names)})"
|
||||
|
||||
# Remove any other executable versions to avoid confusion
|
||||
for exe_name in exe_names:
|
||||
if exe_name != exe_path.name:
|
||||
other_exe = target_dir / exe_name
|
||||
if other_exe.is_file():
|
||||
self.logger.info(f"Removing other version executable: {other_exe}")
|
||||
try:
|
||||
other_exe.unlink()
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to remove {other_exe}: {e}")
|
||||
|
||||
# Set executable permissions
|
||||
try:
|
||||
@@ -194,13 +252,36 @@ class TTWInstallerHandler:
|
||||
|
||||
def is_ttw_installer_update_available(self) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||
"""
|
||||
Check GitHub for the latest TTW_Linux_Installer release and compare with installed version.
|
||||
Returns (update_available, installed_version, latest_version).
|
||||
Check if TTW_Linux_Installer update is available.
|
||||
If a version is pinned, compares against pinned version instead of latest.
|
||||
Returns (update_available, installed_version, target_version).
|
||||
"""
|
||||
installed = self.get_installed_ttw_installer_version()
|
||||
|
||||
# If we have a pinned version, compare against that instead of latest
|
||||
if TTW_INSTALLER_PINNED_VERSION:
|
||||
if not installed:
|
||||
# No version recorded - check if executable exists to infer version
|
||||
if self.ttw_installer_installed and self.ttw_installer_executable_path:
|
||||
exe_name = self.ttw_installer_executable_path.name
|
||||
# If pinned to 0.0.7 but found mpi_installer, it's wrong version
|
||||
if TTW_INSTALLER_PINNED_VERSION == "0.0.7" and exe_name == "mpi_installer":
|
||||
return (True, None, TTW_INSTALLER_PINNED_VERSION)
|
||||
# If pinned to 0.0.7 and found ttw_linux_gui, assume correct
|
||||
elif TTW_INSTALLER_PINNED_VERSION == "0.0.7" and exe_name == "ttw_linux_gui":
|
||||
return (False, None, TTW_INSTALLER_PINNED_VERSION)
|
||||
# Not installed - don't show as update available
|
||||
return (False, None, TTW_INSTALLER_PINNED_VERSION)
|
||||
|
||||
# Compare against pinned version
|
||||
if installed != TTW_INSTALLER_PINNED_VERSION:
|
||||
# Installed version doesn't match pinned - show as out of date (allows downgrade)
|
||||
return (True, installed, TTW_INSTALLER_PINNED_VERSION)
|
||||
else:
|
||||
return (False, installed, TTW_INSTALLER_PINNED_VERSION)
|
||||
|
||||
# No pinned version - check against latest release (original behavior)
|
||||
# If executable exists but no version is recorded, don't show as "out of date"
|
||||
# This can happen if the executable was installed before version tracking was added
|
||||
if not installed and self.ttw_installer_installed:
|
||||
self.logger.info("TTW_Linux_Installer executable found but no version recorded in config")
|
||||
# Don't treat as update available - just show as "Ready" (unknown version)
|
||||
@@ -220,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
|
||||
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):
|
||||
@@ -144,267 +146,6 @@ class ModlistService:
|
||||
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'
|
||||
@@ -485,24 +485,23 @@ class NativeSteamService:
|
||||
if proton_version is None:
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.backend.handlers.wine_utils import WineUtils
|
||||
config_handler = ConfigHandler()
|
||||
game_proton_path = config_handler.get_game_proton_path()
|
||||
|
||||
if game_proton_path and game_proton_path != 'auto':
|
||||
# User has selected Game Proton - use it
|
||||
proton_version = os.path.basename(game_proton_path)
|
||||
# Convert to Steam format
|
||||
if not proton_version.startswith('GE-Proton'):
|
||||
proton_version = proton_version.lower().replace(' - ', '_').replace(' ', '_').replace('-', '_')
|
||||
if not proton_version.startswith('proton'):
|
||||
proton_version = f"proton_{proton_version}"
|
||||
resolved = WineUtils.resolve_steam_compat_name(game_proton_path)
|
||||
if resolved:
|
||||
proton_version = resolved
|
||||
logger.info(f"Using Game Proton from settings: {proton_version}")
|
||||
else:
|
||||
# Fallback to auto-detect if Game Proton not set
|
||||
from jackify.backend.handlers.wine_utils import WineUtils
|
||||
logger.warning(f"Could not resolve compat name for '{game_proton_path}', falling back to auto")
|
||||
game_proton_path = None
|
||||
|
||||
if not game_proton_path or game_proton_path == 'auto':
|
||||
best_proton = WineUtils.select_best_proton()
|
||||
if best_proton:
|
||||
proton_version = best_proton['name']
|
||||
proton_version = best_proton.get('steam_compat_name') or WineUtils.resolve_steam_compat_name(best_proton['path'])
|
||||
logger.info(f"Auto-detected Game Proton: {proton_version}")
|
||||
else:
|
||||
proton_version = "proton_experimental"
|
||||
@@ -575,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():
|
||||
def _send_desktop_notification(self, title: str, message: str) -> None:
|
||||
"""Send a desktop notification via notify-send (Linux). No-op on failure."""
|
||||
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
|
||||
"""
|
||||
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,6 +326,7 @@ Path={src_dir}
|
||||
f"Please open this URL manually:\n{auth_url}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Wait for callback via jackify:// protocol
|
||||
if not self._wait_for_callback():
|
||||
return None
|
||||
@@ -771,3 +356,5 @@ Path={src_dir}
|
||||
logger.error("Failed to exchange authorization code for token")
|
||||
|
||||
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
|
||||
|
||||
# Verify the app is actually installed (not just directory exists)
|
||||
result = subprocess.run(['flatpak', 'list', '--app'],
|
||||
|
||||
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
|
||||
env = _get_clean_subprocess_env()
|
||||
result = subprocess.run(
|
||||
[flatpak_cmd, "list", "--app"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL, # Suppress stderr to avoid error messages
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=5)
|
||||
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"],
|
||||
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,9 +328,9 @@ 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"],
|
||||
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
|
||||
@@ -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()
|
||||
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,7 +529,8 @@ 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'],
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user