mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 00:17:58 +02:00
Sync from development - prepare for v0.4.0
This commit is contained in:
@@ -171,8 +171,7 @@ class ModlistInstallCLI(
|
||||
self.shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck)
|
||||
self.context = {}
|
||||
# Use standard logging (no file handler)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.logger.propagate = False # Prevent duplicate logs if root logger is also configured
|
||||
self.logger = logging.getLogger('jackify-cli')
|
||||
|
||||
# Initialize Wabbajack parser for game detection
|
||||
self.wabbajack_parser = WabbajackParser()
|
||||
@@ -238,4 +237,3 @@ class ModlistInstallCLI(
|
||||
|
||||
print(auth_display)
|
||||
print(f"{COLOR_INFO}----------------------------------------{COLOR_RESET}")
|
||||
|
||||
|
||||
@@ -170,6 +170,7 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
proc = self._current_process
|
||||
|
||||
buffer = b''
|
||||
inline_progress_active = False
|
||||
while True:
|
||||
chunk = proc.stdout.read(1)
|
||||
if not chunk:
|
||||
@@ -185,7 +186,16 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
else:
|
||||
buffer = b''
|
||||
continue
|
||||
print(line, end='')
|
||||
clean_line = line.rstrip('\r\n')
|
||||
if clean_line.startswith("Installing files "):
|
||||
print(f"\r{clean_line}", end='')
|
||||
sys.stdout.flush()
|
||||
inline_progress_active = True
|
||||
else:
|
||||
if inline_progress_active:
|
||||
print()
|
||||
inline_progress_active = False
|
||||
print(line, end='')
|
||||
buffer = b''
|
||||
elif chunk == b'\r':
|
||||
line = buffer.decode('utf-8', errors='replace')
|
||||
@@ -196,7 +206,15 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
else:
|
||||
buffer = b''
|
||||
continue
|
||||
print(line, end='')
|
||||
clean_line = line.rstrip('\r\n')
|
||||
if clean_line.startswith("Installing files "):
|
||||
print(f"\r{clean_line}", end='')
|
||||
inline_progress_active = True
|
||||
else:
|
||||
if inline_progress_active:
|
||||
print()
|
||||
inline_progress_active = False
|
||||
print(line, end='')
|
||||
sys.stdout.flush()
|
||||
buffer = b''
|
||||
|
||||
@@ -209,8 +227,14 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
else:
|
||||
line = ''
|
||||
if line:
|
||||
if inline_progress_active:
|
||||
print()
|
||||
inline_progress_active = False
|
||||
print(line, end='')
|
||||
|
||||
if inline_progress_active:
|
||||
print()
|
||||
|
||||
proc.wait()
|
||||
self._current_process = None
|
||||
if proc.returncode != 0:
|
||||
@@ -343,7 +367,10 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
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}")
|
||||
print(
|
||||
f"{COLOR_PROMPT}Would you like to add '{shortcut_name}' to Steam and configure it now? "
|
||||
f"Steam will restart and close any running game.{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}'")
|
||||
|
||||
@@ -383,11 +410,30 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
start_time = time.time()
|
||||
|
||||
def progress_callback(message):
|
||||
noisy_patterns = (
|
||||
"using bundled tools directory",
|
||||
"bundled tools available",
|
||||
"checking winetricks dependencies",
|
||||
"(bundled)",
|
||||
"(system)",
|
||||
"wget",
|
||||
"curl",
|
||||
"aria2c",
|
||||
"sha256sum",
|
||||
"cabextract",
|
||||
)
|
||||
message_lc = message.lower()
|
||||
if any(pattern in message_lc for pattern in noisy_patterns):
|
||||
# Keep dependency/tool chatter in logs only for CLI readability.
|
||||
self.logger.debug("Automated prefix detail: %s", message)
|
||||
return
|
||||
|
||||
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}]"
|
||||
self.logger.info("Automated prefix progress: %s", message)
|
||||
print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}")
|
||||
|
||||
try:
|
||||
@@ -534,6 +580,58 @@ class ModlistOperationsConfigurationCLIMixin:
|
||||
if configuration_success:
|
||||
print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}")
|
||||
self.logger.info("Post-installation configuration completed successfully")
|
||||
try:
|
||||
# Ensure CLI install flow gets the same VNV automation behavior as GUI.
|
||||
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
|
||||
modlist_name_for_automation = self.context.get('modlist_name') or shortcut_name or ""
|
||||
def _confirm_vnv(description: str) -> bool:
|
||||
print(f"\n{description}\n")
|
||||
try:
|
||||
user_input = input(f"{COLOR_PROMPT}Run VNV post-install automation now? (Y/n): {COLOR_RESET}").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return False
|
||||
return user_input in ("", "y", "yes")
|
||||
def _manual_vnv_file(title: str, instructions: str):
|
||||
print(f"\n{COLOR_WARNING}{title}{COLOR_RESET}")
|
||||
print(instructions)
|
||||
try:
|
||||
file_input = input(f"{COLOR_PROMPT}Path to downloaded file: {COLOR_RESET}").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return None
|
||||
if not file_input:
|
||||
return None
|
||||
selected = Path(file_input).expanduser().resolve()
|
||||
return selected if selected.exists() else None
|
||||
automation_ran, vnv_error = run_vnv_automation_if_applicable(
|
||||
modlist_name=modlist_name_for_automation,
|
||||
modlist_install_location=Path(install_dir_str),
|
||||
game_root=None, # Auto-detect from modlist structure.
|
||||
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
|
||||
progress_callback=lambda msg: print(msg),
|
||||
manual_file_callback=_manual_vnv_file,
|
||||
confirmation_callback=_confirm_vnv,
|
||||
)
|
||||
if automation_ran and not vnv_error:
|
||||
print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}")
|
||||
if vnv_error:
|
||||
print(f"{COLOR_WARNING}VNV automation encountered an error: {vnv_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 vnv_err:
|
||||
self.logger.error("VNV post-install automation failed: %s", vnv_err, exc_info=True)
|
||||
print(f"{COLOR_WARNING}VNV automation could not be completed. Check logs for details.{COLOR_RESET}")
|
||||
try:
|
||||
# v0.4.0 contract: offer TTW flow for eligible FNV lists (e.g., Begin Again).
|
||||
from jackify.backend.handlers.modlist_install_cli_ttw import prompt_ttw_if_eligible
|
||||
|
||||
prompt_ttw_if_eligible(
|
||||
install_dir_str,
|
||||
self.context.get('modlist_name') or shortcut_name or "",
|
||||
)
|
||||
except Exception as ttw_err:
|
||||
self.logger.error("TTW post-install prompt failed: %s", ttw_err, exc_info=True)
|
||||
print(f"{COLOR_WARNING}TTW integration prompt failed. Check logs for details.{COLOR_RESET}")
|
||||
else:
|
||||
print(f"{COLOR_WARNING}Configuration had some issues but completed.{COLOR_RESET}")
|
||||
self.logger.warning("Post-installation configuration had issues")
|
||||
|
||||
@@ -92,7 +92,7 @@ class EnginePerformanceMonitor:
|
||||
# Also monitor the parent Python process for comparison
|
||||
try:
|
||||
self._parent_process = psutil.Process(os.getpid())
|
||||
except:
|
||||
except Exception:
|
||||
self._parent_process = None
|
||||
|
||||
self._monitoring = True
|
||||
@@ -220,7 +220,7 @@ class EnginePerformanceMonitor:
|
||||
parent_cpu_percent = self._parent_process.cpu_percent()
|
||||
parent_memory_info = self._parent_process.memory_info()
|
||||
parent_memory_mb = parent_memory_info.rss / (1024 * 1024)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get I/O info
|
||||
|
||||
@@ -15,6 +15,7 @@ class GameDetector:
|
||||
'skyrim': ['Skyrim Special Edition', 'Skyrim'],
|
||||
'fallout4': ['Fallout 4'],
|
||||
'falloutnv': ['Fallout New Vegas'],
|
||||
'fallout3': ['Fallout 3'],
|
||||
'oblivion': ['Oblivion'],
|
||||
'starfield': ['Starfield'],
|
||||
'oblivion_remastered': ['Oblivion Remastered']
|
||||
@@ -34,6 +35,8 @@ class GameDetector:
|
||||
return 'fallout4'
|
||||
elif any(keyword in modlist_lower for keyword in ['fallout new vegas', 'fonv', 'fnv', 'new vegas', 'nvse']):
|
||||
return 'falloutnv'
|
||||
elif any(keyword in modlist_lower for keyword in ['fallout 3', 'fo3', 'fallout3', 'fose']):
|
||||
return 'fallout3'
|
||||
elif any(keyword in modlist_lower for keyword in ['oblivion', 'obse', 'shivering isles']):
|
||||
return 'oblivion'
|
||||
elif any(keyword in modlist_lower for keyword in ['starfield', 'sf', 'starfieldse']):
|
||||
@@ -108,6 +111,12 @@ class GameDetector:
|
||||
'required_dlc': [],
|
||||
'compatibility_tools': ['protontricks', 'winetricks']
|
||||
},
|
||||
'fallout3': {
|
||||
'launcher': 'FOSE',
|
||||
'min_proton_version': '5.0',
|
||||
'required_dlc': [],
|
||||
'compatibility_tools': ['protontricks', 'winetricks']
|
||||
},
|
||||
'oblivion': {
|
||||
'launcher': 'OBSE',
|
||||
'min_proton_version': '5.0',
|
||||
@@ -173,6 +182,7 @@ class GameDetector:
|
||||
'skyrim': 'SKSE',
|
||||
'fallout4': 'F4SE',
|
||||
'falloutnv': 'NVSE',
|
||||
'fallout3': 'FOSE',
|
||||
'oblivion': 'OBSE',
|
||||
'starfield': 'SFSE',
|
||||
'oblivion_remastered': 'OBSE'
|
||||
@@ -205,6 +215,7 @@ class GameDetector:
|
||||
'skyrim': ['vcrun2019', 'dotnet48', 'dxvk'],
|
||||
'fallout4': ['vcrun2019', 'dotnet48', 'dxvk'],
|
||||
'falloutnv': ['vcrun2019', 'dotnet48'],
|
||||
'fallout3': ['vcrun2019', 'dotnet48'],
|
||||
'oblivion': ['vcrun2019', 'dotnet48'],
|
||||
'starfield': ['vcrun2022', 'dotnet6', 'dotnet7', 'dxvk'],
|
||||
'oblivion_remastered': ['vcrun2022', 'dotnet6', 'dotnet7', 'dxvk']
|
||||
@@ -222,6 +233,7 @@ class GameDetector:
|
||||
'skyrim': ['SkyrimSE.exe', 'Skyrim.exe'],
|
||||
'fallout4': ['Fallout4.exe'],
|
||||
'falloutnv': ['FalloutNV.exe'],
|
||||
'fallout3': ['Fallout3.exe'],
|
||||
'oblivion': ['Oblivion.exe']
|
||||
}
|
||||
|
||||
@@ -250,6 +262,11 @@ class GameDetector:
|
||||
'config_dirs': ['Data', 'Saves'],
|
||||
'registry_keys': ['HKEY_LOCAL_MACHINE\\SOFTWARE\\Bethesda Softworks\\FalloutNV']
|
||||
},
|
||||
'fallout3': {
|
||||
'ini_files': ['Fallout.ini', 'FalloutPrefs.ini'],
|
||||
'config_dirs': ['Data', 'Saves'],
|
||||
'registry_keys': ['HKEY_LOCAL_MACHINE\\SOFTWARE\\Bethesda Softworks\\Fallout3']
|
||||
},
|
||||
'oblivion': {
|
||||
'ini_files': ['Oblivion.ini'],
|
||||
'config_dirs': ['Data', 'Saves'],
|
||||
|
||||
@@ -32,7 +32,6 @@ from .resolution_handler import ResolutionHandler
|
||||
from .protontricks_handler import ProtontricksHandler
|
||||
from .path_handler import PathHandler
|
||||
from .vdf_handler import VDFHandler
|
||||
from .mo2_handler import MO2Handler
|
||||
from jackify.shared.ui_utils import print_section_header
|
||||
from .completers import path_completer
|
||||
|
||||
@@ -72,7 +71,6 @@ class MenuHandler:
|
||||
steamdeck=self.config_handler.settings.get('steamdeck', False),
|
||||
verbose=False
|
||||
)
|
||||
self.mo2_handler = MO2Handler(self)
|
||||
|
||||
def display_banner(self):
|
||||
"""Display the application banner - DEPRECATED: Banner display should be handled by frontend"""
|
||||
|
||||
@@ -82,22 +82,6 @@ class ModlistMenuHandler:
|
||||
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.
|
||||
@@ -269,6 +253,16 @@ class ModlistMenuHandler:
|
||||
|
||||
# Use automated prefix service for modern workflow
|
||||
print(f"\n{COLOR_INFO}Using automated Steam setup workflow...{COLOR_RESET}")
|
||||
|
||||
# CLI safety warning: this workflow will restart Steam as part of shortcut/prefix setup.
|
||||
print("\n" + "-" * 28)
|
||||
print(
|
||||
f"{COLOR_PROMPT}Configure New Modlist will restart Steam and close any running game.{COLOR_RESET}"
|
||||
)
|
||||
continue_choice = input(f"{COLOR_PROMPT}Continue with Configure New now? (Y/n): {COLOR_RESET}").strip().lower()
|
||||
if continue_choice == 'n':
|
||||
print(f"{COLOR_INFO}Configuration cancelled before Steam restart.{COLOR_RESET}")
|
||||
return True
|
||||
|
||||
from ..services.automated_prefix_service import AutomatedPrefixService
|
||||
prefix_service = AutomatedPrefixService()
|
||||
@@ -441,7 +435,15 @@ class ModlistMenuHandler:
|
||||
Shared configuration phase for both new and existing modlists.
|
||||
Expects context dict with keys: name, appid, path (at minimum).
|
||||
"""
|
||||
import os
|
||||
self.logger.debug(f"[DEBUG] Entering run_modlist_configuration_phase with context: {context}")
|
||||
# Write nxmhandler.ini to suppress MO2's NXM Handling popup on first launch.
|
||||
# This must happen before MO2 runs for the first time, so do it here rather than
|
||||
# relying on callers to remember.
|
||||
_mo2_exe = context.get('mo2_exe_path') or os.path.join(context.get('path', ''), 'ModOrganizer.exe')
|
||||
_mo2_dir = os.path.dirname(_mo2_exe)
|
||||
if _mo2_dir and os.path.isdir(_mo2_dir):
|
||||
self.shortcut_handler.write_nxmhandler_ini(_mo2_dir, _mo2_exe)
|
||||
# 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']:
|
||||
@@ -454,7 +456,6 @@ class ModlistMenuHandler:
|
||||
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:
|
||||
@@ -501,10 +502,22 @@ class ModlistMenuHandler:
|
||||
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
|
||||
filtered_prefixes = (
|
||||
"Using bundled tools directory (after system PATH):",
|
||||
"Bundled tools available:",
|
||||
)
|
||||
msg_lc = msg.lower().strip()
|
||||
if msg.startswith(filtered_prefixes):
|
||||
return
|
||||
# Suppress per-tool dependency detail lines like:
|
||||
# " wget: /usr/bin/wget (system)" / " 7z: ... (bundled)".
|
||||
if msg.startswith(" ") and (
|
||||
"(system)" in msg_lc or "(bundled)" in msg_lc or "not found" in msg_lc
|
||||
):
|
||||
return
|
||||
if status_line:
|
||||
print("\r" + " " * len(status_line), end="\r")
|
||||
if gui_mode:
|
||||
@@ -526,28 +539,29 @@ class ModlistMenuHandler:
|
||||
if status_line:
|
||||
print()
|
||||
|
||||
# Configure ENB for Linux compatibility (non-blocking, same as GUI)
|
||||
# Configure ENB for Linux compatibility (non-blocking).
|
||||
# In GUI mode, modlist_service.py handles ENB after this function returns,
|
||||
# so skip here to avoid running it twice.
|
||||
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
|
||||
if not gui_mode:
|
||||
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)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"ENB configuration skipped due to error: {e}")
|
||||
|
||||
# Run modlist-specific post-install automation (e.g., VNV) before showing completion
|
||||
# Only in CLI mode - GUI handles this in install_modlist.py
|
||||
@@ -560,17 +574,37 @@ class ModlistMenuHandler:
|
||||
modlist_path = Path(context.get('path', ''))
|
||||
|
||||
try:
|
||||
print("")
|
||||
print("Running VNV post-install automation...")
|
||||
def _confirm_vnv(description: str) -> bool:
|
||||
print(f"\n{description}\n")
|
||||
try:
|
||||
user_input = input(f"{COLOR_PROMPT}Run VNV post-install automation now? (Y/n): {COLOR_RESET}").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return False
|
||||
return user_input in ("", "y", "yes")
|
||||
|
||||
def _manual_vnv_file(title: str, instructions: str):
|
||||
print(f"\n{COLOR_WARNING}{title}{COLOR_RESET}")
|
||||
print(instructions)
|
||||
try:
|
||||
file_input = input(f"{COLOR_PROMPT}Path to downloaded file: {COLOR_RESET}").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return None
|
||||
if not file_input:
|
||||
return None
|
||||
selected = Path(file_input).expanduser().resolve()
|
||||
return selected if selected.exists() else None
|
||||
|
||||
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
|
||||
manual_file_callback=_manual_vnv_file,
|
||||
confirmation_callback=_confirm_vnv
|
||||
)
|
||||
if automation_ran and not error:
|
||||
print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}")
|
||||
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}")
|
||||
@@ -578,6 +612,22 @@ class ModlistMenuHandler:
|
||||
self.logger.debug(f"VNV automation check skipped: {e}")
|
||||
# Not an error - just means VNV automation wasn't applicable
|
||||
|
||||
if not gui_mode:
|
||||
try:
|
||||
from jackify.backend.handlers.modlist_install_cli_ttw import prompt_ttw_if_eligible
|
||||
|
||||
prompt_ttw_if_eligible(
|
||||
context.get('path', ''),
|
||||
context.get('name', '') or '',
|
||||
)
|
||||
except Exception as ttw_err:
|
||||
self.logger.error("TTW post-config prompt failed: %s", ttw_err, exc_info=True)
|
||||
print(f"{COLOR_WARNING}TTW integration prompt failed. Check logs for details.{COLOR_RESET}")
|
||||
|
||||
is_existing_flow = context.get("modlist_source") == "existing"
|
||||
completion_title = "Modlist Configuration complete!" if is_existing_flow else "Modlist Install and Configuration complete!"
|
||||
completion_log_file = "Configure_Existing_Modlist_workflow.log" if is_existing_flow else "Configure_New_Modlist_workflow.log"
|
||||
|
||||
print("")
|
||||
print("")
|
||||
print("") # Extra blank line before completion
|
||||
@@ -585,7 +635,7 @@ class ModlistMenuHandler:
|
||||
print("= Configuration phase complete =")
|
||||
print("=" * 35)
|
||||
print("")
|
||||
print("Modlist Install and Configuration complete!")
|
||||
print(completion_title)
|
||||
print(f"• You should now be able to Launch '{context.get('name')}' through Steam")
|
||||
print("• Congratulations and enjoy the game!")
|
||||
print("")
|
||||
@@ -608,7 +658,7 @@ class ModlistMenuHandler:
|
||||
# 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")
|
||||
print(f"Detailed log available at: {get_jackify_logs_dir()}/{completion_log_file}")
|
||||
# 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}")
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
import requests
|
||||
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.
|
||||
"""
|
||||
def __init__(self, menu_handler):
|
||||
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
|
||||
home = Path.home().resolve()
|
||||
dangerous = [Path('/'), Path('/home'), Path('/root'), home]
|
||||
return any(path.resolve() == d for d in dangerous)
|
||||
|
||||
def install_mo2(self):
|
||||
os.system('cls' if os.name == 'nt' else 'clear')
|
||||
# Banner display handled by frontend
|
||||
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}")
|
||||
return False
|
||||
# 2. Prompt for install location
|
||||
default_dir = Path.home() / "ModOrganizer2"
|
||||
prompt = f"Enter the full path where Mod Organizer 2 should be installed (default: {default_dir}, enter 'q' to cancel)"
|
||||
install_dir = self.menu_handler.get_directory_path(
|
||||
prompt_message=prompt,
|
||||
default_path=default_dir,
|
||||
create_if_missing=False,
|
||||
no_header=True
|
||||
)
|
||||
if not install_dir:
|
||||
print(f"\n{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}\n")
|
||||
return False
|
||||
# Safety: Block dangerous paths
|
||||
if self._is_dangerous_path(install_dir):
|
||||
print(f"\n{COLOR_ERROR}Refusing to install to a dangerous directory: {install_dir}{COLOR_RESET}\n")
|
||||
return False
|
||||
# 3. Ask if user wants to add MO2 to Steam
|
||||
add_to_steam = input(f"Add Mod Organizer 2 as a custom Steam shortcut for Proton? (Y/n): ").strip().lower()
|
||||
add_to_steam = (add_to_steam == '' or add_to_steam.startswith('y'))
|
||||
shortcut_name = None
|
||||
if add_to_steam:
|
||||
shortcut_name = input(f"Enter a name for your new Steam shortcut (default: Mod Organizer 2): ").strip()
|
||||
if not shortcut_name:
|
||||
shortcut_name = "Mod Organizer 2"
|
||||
print_subsection_header('Configuration Phase')
|
||||
time.sleep(0.5)
|
||||
# 4. Create directory if needed, handle existing contents
|
||||
if not install_dir.exists():
|
||||
try:
|
||||
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}")
|
||||
return False
|
||||
else:
|
||||
files = list(install_dir.iterdir())
|
||||
if files:
|
||||
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':
|
||||
print(f"{COLOR_INFO}Cancelled by user. Please choose a different directory if you want to keep existing files.{COLOR_RESET}\n")
|
||||
return False
|
||||
for f in files:
|
||||
try:
|
||||
if f.is_dir():
|
||||
shutil.rmtree(f)
|
||||
else:
|
||||
f.unlink()
|
||||
except Exception as e:
|
||||
print(f"{COLOR_ERROR}Failed to delete {f}: {e}{COLOR_RESET}")
|
||||
show_status(f"Deleted all contents of {install_dir}")
|
||||
|
||||
# 5. Fetch latest MO2 release info from GitHub
|
||||
show_status("Fetching latest Mod Organizer 2 release info...")
|
||||
try:
|
||||
response = requests.get("https://api.github.com/repos/ModOrganizer2/modorganizer/releases/latest", timeout=15, verify=True)
|
||||
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}")
|
||||
return False
|
||||
|
||||
# 6. Find the correct .7z asset (exclude -pdbs, -src, etc)
|
||||
asset = None
|
||||
for a in release.get('assets', []):
|
||||
name = a['name']
|
||||
if re.match(r"Mod\.Organizer-\d+\.\d+(\.\d+)?\.7z$", name):
|
||||
asset = a
|
||||
break
|
||||
if not asset:
|
||||
print(f"{COLOR_ERROR}[ERROR] Could not find main MO2 .7z asset in latest release.{COLOR_RESET}")
|
||||
return False
|
||||
|
||||
# 7. Download the archive
|
||||
show_status(f"Downloading {asset['name']}...")
|
||||
archive_path = install_dir / asset['name']
|
||||
try:
|
||||
with requests.get(asset['browser_download_url'], stream=True, timeout=60, verify=True) as r:
|
||||
r.raise_for_status()
|
||||
with open(archive_path, 'wb') as f:
|
||||
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}")
|
||||
return False
|
||||
|
||||
# 8. Extract using 7z (suppress noisy output)
|
||||
show_status(f"Extracting to {install_dir}...")
|
||||
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}")
|
||||
return False
|
||||
except Exception as e:
|
||||
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}")
|
||||
return False
|
||||
else:
|
||||
show_status(f"MO2 installed at: {mo2_exe.parent}")
|
||||
|
||||
# 10. Add to Steam if requested
|
||||
if add_to_steam and self.shortcut_handler:
|
||||
show_status("Creating Steam shortcut...")
|
||||
try:
|
||||
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=str(mo2_exe),
|
||||
start_dir=str(mo2_exe.parent),
|
||||
launch_options="%command%",
|
||||
tags=["Jackify"],
|
||||
proton_version="proton_experimental"
|
||||
)
|
||||
if not success or not app_id:
|
||||
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)
|
||||
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(f"\n{COLOR_PROMPT}Restart Steam automatically now? (Y/n): {COLOR_RESET}").strip().lower()
|
||||
if restart_choice != 'n':
|
||||
if hasattr(self.shortcut_handler, 'secure_steam_restart'):
|
||||
print("Restarting Steam...")
|
||||
self.shortcut_handler.secure_steam_restart()
|
||||
print("\nAfter restarting, you MUST perform the manual Proton setup steps:")
|
||||
print(f" 1. Locate '{COLOR_INFO}{shortcut_name}{COLOR_RESET}' in your Steam Library")
|
||||
print(" 2. Right-click and select 'Properties'")
|
||||
print(" 3. Switch to the 'Compatibility' tab")
|
||||
print(" 4. Check '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}{shortcut_name}{COLOR_RESET}' from your Steam Library")
|
||||
print(" 8. If Mod Organizer opens or produces any error message, that's normal")
|
||||
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}")
|
||||
|
||||
print(f"{COLOR_SUCCESS}Mod Organizer 2 has been installed successfully!{COLOR_RESET}\n")
|
||||
return True
|
||||
@@ -329,6 +329,9 @@ class ModlistDetectionMixin:
|
||||
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 'fose' in content or 'fose_loader' in content or ('fallout 3' in content and 'fallout 4' not in content):
|
||||
self.logger.info("Detected FO3 via ModOrganizer.ini markers")
|
||||
return "fo3"
|
||||
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"
|
||||
@@ -353,6 +356,10 @@ class ModlistDetectionMixin:
|
||||
if nvse_loader.exists():
|
||||
self.logger.info(f"Detected FNV modlist: found nvse_loader.exe in '{base}'")
|
||||
return "fnv"
|
||||
fose_loader = base / "fose_loader.exe"
|
||||
if fose_loader.exists():
|
||||
self.logger.info(f"Detected FO3 modlist: found fose_loader.exe in '{base}'")
|
||||
return "fo3"
|
||||
enderal_launcher = base / "Enderal Launcher.exe"
|
||||
if enderal_launcher.exists():
|
||||
self.logger.info(f"Detected Enderal modlist: found Enderal Launcher.exe in '{base}'")
|
||||
@@ -366,6 +373,9 @@ class ModlistDetectionMixin:
|
||||
if 'fallout new vegas' in gt or gt == 'fnv':
|
||||
self.logger.info("Heuristic detection: game_var indicates FNV")
|
||||
return "fnv"
|
||||
if 'fallout 3' in gt or gt == 'fo3':
|
||||
self.logger.info("Heuristic detection: game_var indicates FO3")
|
||||
return "fo3"
|
||||
if 'enderal' in gt:
|
||||
self.logger.info("Heuristic detection: game_var indicates Enderal")
|
||||
return "enderal"
|
||||
|
||||
@@ -258,6 +258,7 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
try:
|
||||
# Read output in binary mode to properly handle carriage returns
|
||||
buffer = b''
|
||||
inline_progress_active = False
|
||||
last_progress_time = time.time()
|
||||
|
||||
while True:
|
||||
@@ -282,7 +283,16 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
continue
|
||||
# Enhance Nexus download errors with modlist context
|
||||
enhanced_line = self._enhance_nexus_error(line)
|
||||
print(enhanced_line, end='')
|
||||
clean_line = enhanced_line.rstrip('\r\n')
|
||||
if clean_line.startswith("Installing files "):
|
||||
print(f"\r{clean_line}", end='')
|
||||
sys.stdout.flush()
|
||||
inline_progress_active = True
|
||||
else:
|
||||
if inline_progress_active:
|
||||
print()
|
||||
inline_progress_active = False
|
||||
print(enhanced_line, end='')
|
||||
buffer = b''
|
||||
last_progress_time = time.time()
|
||||
elif chunk == b'\r':
|
||||
@@ -300,7 +310,15 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
continue
|
||||
# Enhance Nexus download errors with modlist context
|
||||
enhanced_line = self._enhance_nexus_error(line)
|
||||
print(enhanced_line, end='')
|
||||
clean_line = enhanced_line.rstrip('\r\n')
|
||||
if clean_line.startswith("Installing files "):
|
||||
print(f"\r{clean_line}", end='')
|
||||
inline_progress_active = True
|
||||
else:
|
||||
if inline_progress_active:
|
||||
print()
|
||||
inline_progress_active = False
|
||||
print(enhanced_line, end='')
|
||||
sys.stdout.flush()
|
||||
buffer = b''
|
||||
last_progress_time = time.time()
|
||||
@@ -314,7 +332,13 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
# Print any remaining buffer content
|
||||
if buffer:
|
||||
line = buffer.decode('utf-8', errors='replace')
|
||||
if inline_progress_active:
|
||||
print()
|
||||
inline_progress_active = False
|
||||
print(line, end='')
|
||||
|
||||
if inline_progress_active:
|
||||
print()
|
||||
|
||||
proc.wait()
|
||||
|
||||
@@ -415,7 +439,10 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
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}")
|
||||
print(
|
||||
f"{COLOR_PROMPT}Would you like to add '{shortcut_name}' to Steam and configure it now? "
|
||||
f"Steam will restart and close any running game.{COLOR_RESET}"
|
||||
)
|
||||
configure_choice = input(f"{COLOR_PROMPT}Configure now? (Y/n): {COLOR_RESET}").strip().lower()
|
||||
|
||||
if configure_choice == 'n':
|
||||
@@ -424,71 +451,61 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
|
||||
# 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'.")
|
||||
|
||||
from ..services.automated_prefix_service import AutomatedPrefixService
|
||||
prefix_service = AutomatedPrefixService()
|
||||
|
||||
def _cli_progress(message):
|
||||
noisy_patterns = (
|
||||
"using bundled tools directory",
|
||||
"bundled tools available",
|
||||
"checking winetricks dependencies",
|
||||
"(bundled)",
|
||||
"(system)",
|
||||
"wget",
|
||||
"curl",
|
||||
"aria2c",
|
||||
"sha256sum",
|
||||
"cabextract",
|
||||
)
|
||||
message_lc = message.lower()
|
||||
if any(pattern in message_lc for pattern in noisy_patterns):
|
||||
self.logger.debug("Automated prefix detail: %s", message)
|
||||
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}")
|
||||
print(f"{COLOR_INFO}{message}{COLOR_RESET}")
|
||||
|
||||
try:
|
||||
_result = prefix_service.run_working_workflow(
|
||||
shortcut_name, install_dir_str, mo2_exe_path, _cli_progress, steamdeck=self.steamdeck
|
||||
)
|
||||
except Exception as _wf_err:
|
||||
from jackify.shared.errors import JackifyError
|
||||
if isinstance(_wf_err, JackifyError):
|
||||
self.logger.error(f"Automated prefix setup failed: {_wf_err.message}")
|
||||
print(f"{COLOR_ERROR}{_wf_err.message}{COLOR_RESET}")
|
||||
if _wf_err.suggestion:
|
||||
print(f"{COLOR_INFO}What to do: {_wf_err.suggestion}{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
|
||||
self.logger.error(f"Automated prefix setup failed: {_wf_err}")
|
||||
print(f"{COLOR_ERROR}Automated prefix setup failed. Check logs for details.{COLOR_RESET}")
|
||||
return
|
||||
|
||||
if isinstance(_result, tuple) and len(_result) == 4:
|
||||
success, _prefix_path, app_id, _last_ts = _result
|
||||
else:
|
||||
success, app_id = False, None
|
||||
|
||||
if not success:
|
||||
self.logger.error("Automated prefix setup failed")
|
||||
print(f"{COLOR_ERROR}Automated prefix setup failed. Check logs for details.{COLOR_RESET}")
|
||||
return
|
||||
|
||||
config_context = {
|
||||
'name': shortcut_name,
|
||||
'appid': app_id,
|
||||
@@ -496,17 +513,14 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
'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
|
||||
'manual_steps_completed': True
|
||||
}
|
||||
|
||||
# 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:
|
||||
@@ -524,4 +538,3 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
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}")
|
||||
|
||||
|
||||
@@ -1,13 +1,59 @@
|
||||
"""TTW integration methods for ModlistInstallCLI (Mixin)."""
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_WARNING
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _strip_ansi_control_codes(text: str) -> str:
|
||||
"""Strip ANSI escape/control sequences from CLI output lines."""
|
||||
return re.sub(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])', '', text or '')
|
||||
|
||||
|
||||
def prompt_ttw_if_eligible(install_dir: str, modlist_name: str) -> None:
|
||||
"""Standalone TTW prompt usable outside the mixin context (e.g. CLI configure command).
|
||||
|
||||
Detects game type from ModOrganizer.ini, resolves the best available modlist name,
|
||||
checks whitelist eligibility, and runs the interactive TTW prompt if applicable.
|
||||
"""
|
||||
try:
|
||||
# Detect game type from ModOrganizer.ini
|
||||
mo2_ini = Path(install_dir) / "ModOrganizer.ini"
|
||||
game_type = "skyrim"
|
||||
if mo2_ini.exists():
|
||||
content = mo2_ini.read_text(encoding="utf-8", errors="ignore").lower()
|
||||
if "nvse_loader.exe" in content or "fallout new vegas" in content:
|
||||
game_type = "falloutnv"
|
||||
elif "fose_loader.exe" in content or "fallout 3" in content:
|
||||
game_type = "fallout3"
|
||||
|
||||
if game_type not in ("falloutnv", "fallout_new_vegas"):
|
||||
return
|
||||
|
||||
# Best available name: meta file, then selected_profile, then caller-supplied name
|
||||
from jackify.backend.utils.modlist_meta import get_modlist_name
|
||||
identified_name = get_modlist_name(install_dir) or modlist_name
|
||||
if not identified_name:
|
||||
return
|
||||
|
||||
class _Adapter(ModlistInstallCLITTWMixin):
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.verbose = False
|
||||
self.filesystem_handler = None
|
||||
self.config_handler = None
|
||||
|
||||
_Adapter()._check_and_prompt_ttw_integration(install_dir, game_type, identified_name)
|
||||
except Exception as e:
|
||||
logger.error("TTW post-configure check failed: %s", e, exc_info=True)
|
||||
|
||||
|
||||
class ModlistInstallCLITTWMixin:
|
||||
"""Mixin providing TTW integration methods."""
|
||||
|
||||
@@ -26,7 +72,38 @@ class ModlistInstallCLITTWMixin:
|
||||
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()
|
||||
# Some CLI entrypoint signal handlers currently call sys.exit(), which can interrupt
|
||||
# this prompt unexpectedly. Temporarily convert SIGINT/SIGTERM to KeyboardInterrupt
|
||||
# and keep prompting so users can answer explicitly.
|
||||
original_sigint = signal.getsignal(signal.SIGINT)
|
||||
original_sigterm = signal.getsignal(signal.SIGTERM)
|
||||
|
||||
def _prompt_signal_handler(signum, frame):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
try:
|
||||
signal.signal(signal.SIGINT, _prompt_signal_handler)
|
||||
signal.signal(signal.SIGTERM, _prompt_signal_handler)
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = input(f"{COLOR_PROMPT}Install TTW now? (Y/n): {COLOR_RESET}").strip().lower()
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{COLOR_WARNING}TTW prompt interrupted. Please type yes or no.{COLOR_RESET}")
|
||||
continue
|
||||
except EOFError:
|
||||
print(f"\n{COLOR_WARNING}No input available. Skipping TTW installation.{COLOR_RESET}")
|
||||
return
|
||||
|
||||
if user_input == "":
|
||||
user_input = "y"
|
||||
if user_input in ['yes', 'y', 'no', 'n']:
|
||||
break
|
||||
|
||||
print(f"{COLOR_WARNING}Please answer yes or no.{COLOR_RESET}")
|
||||
finally:
|
||||
signal.signal(signal.SIGINT, original_sigint)
|
||||
signal.signal(signal.SIGTERM, original_sigterm)
|
||||
|
||||
if user_input in ['yes', 'y']:
|
||||
self._launch_ttw_installation(modlist_name, install_dir)
|
||||
@@ -106,15 +183,26 @@ class ModlistInstallCLITTWMixin:
|
||||
|
||||
# Import TTW installation handler
|
||||
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
from pathlib import Path
|
||||
|
||||
system_info = SystemInfo()
|
||||
is_steamdeck = bool(getattr(self, 'steamdeck', False))
|
||||
if not is_steamdeck:
|
||||
try:
|
||||
is_steamdeck = PlatformDetectionService.get_instance().is_steamdeck
|
||||
except Exception:
|
||||
is_steamdeck = False
|
||||
|
||||
filesystem_handler = getattr(self, 'filesystem_handler', None) or FileSystemHandler()
|
||||
config_handler = getattr(self, 'config_handler', None) or ConfigHandler()
|
||||
|
||||
ttw_installer_handler = TTWInstallerHandler(
|
||||
steamdeck=system_info.is_steamdeck if hasattr(system_info, 'is_steamdeck') else False,
|
||||
steamdeck=is_steamdeck,
|
||||
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
|
||||
filesystem_handler=filesystem_handler,
|
||||
config_handler=config_handler
|
||||
)
|
||||
|
||||
# Check if TTW_Linux_Installer is installed
|
||||
@@ -122,7 +210,9 @@ class ModlistInstallCLITTWMixin:
|
||||
|
||||
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()
|
||||
user_input = input(f"{COLOR_PROMPT}Install TTW_Linux_Installer? (Y/n): {COLOR_RESET}").strip().lower()
|
||||
if user_input == "":
|
||||
user_input = "y"
|
||||
|
||||
if user_input not in ['yes', 'y']:
|
||||
print(f"{COLOR_INFO}TTW installation cancelled.{COLOR_RESET}")
|
||||
@@ -152,7 +242,7 @@ class ModlistInstallCLITTWMixin:
|
||||
|
||||
# Prompt for TTW installation directory
|
||||
print(f"\n{COLOR_PROMPT}TTW Installation Directory{COLOR_RESET}")
|
||||
default_ttw_dir = os.path.join(install_dir, 'TTW')
|
||||
default_ttw_dir = os.path.join(install_dir, 'mods', '[NoDelete] Tale of Two Wastelands')
|
||||
print(f"Default: {default_ttw_dir}")
|
||||
ttw_install_dir = input(f"{COLOR_PROMPT}TTW install directory (Enter for default): {COLOR_RESET}").strip()
|
||||
|
||||
@@ -162,14 +252,105 @@ class ModlistInstallCLITTWMixin:
|
||||
# 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}")
|
||||
phase_state = {"current": "Processing", "last_rendered": ""}
|
||||
progress_line_active = {"value": False}
|
||||
|
||||
success, message = ttw_installer_handler.install_ttw_backend(Path(mpi_path), Path(ttw_install_dir))
|
||||
def _ttw_output_callback(line: str):
|
||||
clean = _strip_ansi_control_codes(line or "").strip()
|
||||
if not clean:
|
||||
return
|
||||
|
||||
lower = clean.lower()
|
||||
rendered = ""
|
||||
|
||||
# Match GUI behavior: explicit Loading manifest counter line
|
||||
manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower)
|
||||
if manifest_match:
|
||||
current = int(manifest_match.group(1))
|
||||
total = int(manifest_match.group(2))
|
||||
phase_state["current"] = "Loading manifest"
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
rendered = f"[TTW] {phase_state['current']}: {current:,}/{total:,} ({percent}%)"
|
||||
else:
|
||||
# Match GUI behavior: generic [X/Y] counters with current phase name.
|
||||
progress_match = re.search(r'\[(\d+)/(\d+)\]', clean)
|
||||
if progress_match:
|
||||
current = int(progress_match.group(1))
|
||||
total = int(progress_match.group(2))
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
rendered = f"[TTW] {phase_state['current']}: {current:,}/{total:,} ({percent}%)"
|
||||
else:
|
||||
# Update phase state from milestone-like lines, then echo milestones.
|
||||
if 'manifest' in lower:
|
||||
phase_state["current"] = "Loading manifest"
|
||||
elif any(token in lower for token in ('extract', 'decompress', 'installing', 'copying', 'merge')):
|
||||
phase_state["current"] = clean
|
||||
|
||||
is_milestone = any(token in lower for token in ('===', 'complete', 'finished', 'starting', 'valid'))
|
||||
is_error = 'error:' in lower
|
||||
is_warning = 'warning:' in lower
|
||||
if is_milestone or is_error or is_warning:
|
||||
rendered = f"[TTW] {clean}"
|
||||
|
||||
if not rendered or rendered == phase_state["last_rendered"]:
|
||||
return
|
||||
phase_state["last_rendered"] = rendered
|
||||
if rendered.startswith("[TTW] Loading manifest:") or re.search(r'^\[TTW\] .+?: [\d,]+/[\d,]+ \(\d+%\)$', rendered):
|
||||
# In-place progress updates for counters/phases.
|
||||
print(f"\r{COLOR_INFO}{rendered}{COLOR_RESET}", end="", flush=True)
|
||||
progress_line_active["value"] = True
|
||||
else:
|
||||
# Non-progress milestones/errors get normal line output.
|
||||
if progress_line_active["value"]:
|
||||
print()
|
||||
progress_line_active["value"] = False
|
||||
print(f"{COLOR_INFO}{rendered}{COLOR_RESET}")
|
||||
|
||||
success, message = ttw_installer_handler.install_ttw_backend_with_output_stream(
|
||||
Path(mpi_path),
|
||||
Path(ttw_install_dir),
|
||||
output_callback=_ttw_output_callback,
|
||||
)
|
||||
if progress_line_active["value"]:
|
||||
print()
|
||||
|
||||
if success:
|
||||
ttw_output_path = Path(ttw_install_dir)
|
||||
ttw_version = ""
|
||||
version_match = re.search(r'v?(\d+\.\d+(?:\.\d+)?)', Path(mpi_path).stem, re.IGNORECASE)
|
||||
if version_match:
|
||||
ttw_version = version_match.group(1)
|
||||
|
||||
skip_copy = False
|
||||
mods_dir = Path(install_dir) / "mods"
|
||||
if ttw_output_path.parent == mods_dir:
|
||||
versioned_name = f"[NoDelete] Tale of Two Wastelands {ttw_version}".strip() if ttw_version else "[NoDelete] Tale of Two Wastelands"
|
||||
versioned_path = mods_dir / versioned_name
|
||||
if ttw_output_path != versioned_path and ttw_output_path.exists():
|
||||
if versioned_path.exists():
|
||||
shutil.rmtree(versioned_path)
|
||||
ttw_output_path.rename(versioned_path)
|
||||
ttw_output_path = versioned_path
|
||||
skip_copy = True
|
||||
|
||||
print(f"\n{COLOR_INFO}Integrating TTW into modlist load order...{COLOR_RESET}")
|
||||
integration_success = TTWInstallerHandler.integrate_ttw_into_modlist(
|
||||
ttw_output_path=ttw_output_path,
|
||||
modlist_install_dir=Path(install_dir),
|
||||
ttw_version=ttw_version,
|
||||
skip_copy=skip_copy,
|
||||
)
|
||||
|
||||
if not integration_success:
|
||||
print(f"{COLOR_ERROR}TTW installed, but integration into modlist failed.{COLOR_RESET}")
|
||||
print(f"{COLOR_ERROR}Please check TTW_Install_workflow.log for details.{COLOR_RESET}")
|
||||
return
|
||||
|
||||
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"\nTTW has been installed to: {ttw_output_path}")
|
||||
print(f"TTW has been integrated into '{modlist_name}' (modlist.txt + plugins.txt updated).")
|
||||
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}")
|
||||
@@ -177,4 +358,4 @@ class ModlistInstallCLITTWMixin:
|
||||
|
||||
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}")
|
||||
print(f"{COLOR_ERROR}Error during TTW installation: {e}{COLOR_RESET}")
|
||||
|
||||
@@ -207,8 +207,11 @@ class ModlistWineOpsMixin:
|
||||
# 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:
|
||||
elif "falloutnewvegas" in game or "fnv" in game or "fallout3" in game or "fo3" in game or "oblivion" in game:
|
||||
extras += ["d3dx9_43", "d3dx9"]
|
||||
else:
|
||||
# Unknown game type — install the union of all known component sets
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "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():
|
||||
|
||||
@@ -66,12 +66,12 @@ class OAuthTokenHandler:
|
||||
# Linux machine-id
|
||||
with open('/etc/machine-id', 'r') as f:
|
||||
machine_id = f.read().strip()
|
||||
except:
|
||||
except (OSError, IOError):
|
||||
try:
|
||||
# Alternative locations
|
||||
with open('/var/lib/dbus/machine-id', 'r') as f:
|
||||
machine_id = f.read().strip()
|
||||
except:
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
|
||||
# Combine multiple sources of machine-specific data
|
||||
@@ -221,7 +221,7 @@ class OAuthTokenHandler:
|
||||
# Clean up temp file on error
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except:
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
raise e
|
||||
|
||||
|
||||
@@ -87,10 +87,12 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
# Alternative format: "[timestamp] StatusText (current/total) - speed"
|
||||
# Alternative format: "[timestamp] StatusText (current/total) - speed [- Xunit remaining]"
|
||||
# Example: "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s"
|
||||
# Example (engine 0.4.8+): "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s - 23.1GB remaining"
|
||||
# Timestamp prefix is now optional — engine no longer emits [HH:MM:SS].
|
||||
self.timestamp_status_pattern = re.compile(
|
||||
r'\[[^\]]+\]\s+(.+?)\s+\((\d+)/(\d+)\)\s*-\s*([^\s]+)',
|
||||
r'(?:\[[^\]]+\]\s+)?(.+?)\s+\((\d+)/(\d+)\)\s*-\s*([^\s]+)(?:\s*-\s*([\d.]+)\s*(B|KB|MB|GB|TB)\s+remaining)?',
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
@@ -230,18 +232,33 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
|
||||
if speed_info:
|
||||
operation = self._detect_operation_from_line(status_text)
|
||||
result.speed_info = (operation.value, speed_info)
|
||||
|
||||
|
||||
# Extract remaining size if present (engine 0.4.8+: "- 23.1GB remaining")
|
||||
remaining_val = timestamp_match.group(5)
|
||||
remaining_unit = timestamp_match.group(6)
|
||||
if remaining_val and remaining_unit:
|
||||
remaining_bytes = self._convert_to_bytes(float(remaining_val), remaining_unit)
|
||||
if remaining_bytes > 0 and max_steps > 0 and current_step < max_steps:
|
||||
fraction_done = current_step / max_steps
|
||||
# Estimate total from remaining and fraction; clamp denominator to avoid div/0 near completion
|
||||
estimated_total = remaining_bytes / max(1.0 - fraction_done, 0.01)
|
||||
data_processed = int(estimated_total - remaining_bytes)
|
||||
result.data_info = (max(0, data_processed), int(estimated_total))
|
||||
elif remaining_bytes > 0:
|
||||
result.data_info = (0, int(remaining_bytes))
|
||||
|
||||
# Calculate overall percentage from step progress
|
||||
if max_steps > 0:
|
||||
result.overall_percent = (current_step / max_steps) * 100.0
|
||||
|
||||
|
||||
result.has_progress = True
|
||||
|
||||
# Try .wabbajack download format: "[timestamp] Downloading .wabbajack (size/size) - speed"
|
||||
# Example: "[00:02:08] Downloading .wabbajack (739.2/1947.2MB) - 6.0MB/s"
|
||||
# Also handles: "[00:02:08] Downloading modlist.wabbajack (739.2/1947.2MB) - 6.0MB/s"
|
||||
# Timestamp prefix is optional in newer engine output.
|
||||
wabbajack_download_pattern = re.compile(
|
||||
r'\[[^\]]+\]\s+Downloading\s+([^\s]+\.wabbajack|\.wabbajack)\s+\(([^)]+)\)\s*-\s*([^\s]+)',
|
||||
r'(?:\[[^\]]+\]\s+)?Downloading\s+([^\s]+\.wabbajack|\.wabbajack)\s+\(([^)]+)\)\s*-\s*([^\s]+)',
|
||||
re.IGNORECASE
|
||||
)
|
||||
wabbajack_match = wabbajack_download_pattern.search(line)
|
||||
@@ -294,13 +311,15 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
|
||||
|
||||
# Set phase
|
||||
result.phase = InstallationPhase.DOWNLOAD
|
||||
result.phase_name = f"Downloading {filename}"
|
||||
phase_target = filename
|
||||
if phase_target.lower().startswith("downloading "):
|
||||
phase_target = phase_target[len("downloading "):].strip()
|
||||
result.phase_name = f"Downloading {phase_target}"
|
||||
|
||||
# Create FileProgress entry for .wabbajack file
|
||||
if data_info:
|
||||
current_bytes, total_bytes = data_info
|
||||
percent = (current_bytes / total_bytes) * 100.0 if total_bytes > 0 else 0.0
|
||||
from jackify.shared.progress_models import FileProgress, OperationType
|
||||
file_progress = FileProgress(
|
||||
filename=filename,
|
||||
operation=OperationType.DOWNLOAD,
|
||||
@@ -313,6 +332,50 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
|
||||
|
||||
result.has_progress = True
|
||||
|
||||
# Try to extract install progress format:
|
||||
# "Installing files X/Y (GB/GB) - Converting textures: N/M"
|
||||
install_match = re.match(
|
||||
r'Installing files\s+(\d+)/(\d+)\s+\(([^)]+)\)(?:\s*-\s*Converting textures:\s*(\d+)/(\d+))?',
|
||||
line.strip(), re.IGNORECASE)
|
||||
if install_match:
|
||||
result.phase = InstallationPhase.INSTALL
|
||||
result.step_info = (int(install_match.group(1)), int(install_match.group(2)))
|
||||
data_info = self._parse_data_string(install_match.group(3))
|
||||
if data_info:
|
||||
result.data_info = data_info
|
||||
current_bytes, total_bytes = data_info
|
||||
if total_bytes > 0:
|
||||
result.overall_percent = (current_bytes / total_bytes) * 100.0
|
||||
if install_match.group(4) and install_match.group(5):
|
||||
fp = FileProgress(
|
||||
filename='_tex',
|
||||
operation=OperationType.INSTALL,
|
||||
percent=0.0,
|
||||
speed=-1.0
|
||||
)
|
||||
fp._texture_counter = (int(install_match.group(4)), int(install_match.group(5)))
|
||||
fp._hidden = True
|
||||
result.file_progress = fp
|
||||
result.has_progress = True
|
||||
|
||||
# Conversion-only status line (without "Installing files ...")
|
||||
conversion_match = re.search(r'Converting textures:\s*(\d+)/(\d+)', line, re.IGNORECASE)
|
||||
if conversion_match and not install_match:
|
||||
if not result.phase:
|
||||
result.phase = InstallationPhase.INSTALL
|
||||
if not result.phase_name:
|
||||
result.phase_name = "Converting textures"
|
||||
fp = FileProgress(
|
||||
filename='_tex',
|
||||
operation=OperationType.INSTALL,
|
||||
percent=0.0,
|
||||
speed=-1.0
|
||||
)
|
||||
fp._texture_counter = (int(conversion_match.group(1)), int(conversion_match.group(2)))
|
||||
fp._hidden = True
|
||||
result.file_progress = fp
|
||||
result.has_progress = True
|
||||
|
||||
# Try to extract step information (fallback)
|
||||
if not result.step_info:
|
||||
step_info = self._extract_step_info(line)
|
||||
|
||||
@@ -24,6 +24,12 @@ class ProgressParserExtractionMixin:
|
||||
|
||||
def _extract_step_info(self, line: str) -> Optional[Tuple[int, int]]:
|
||||
"""Extract step information like [12/14]."""
|
||||
line_lower = line.lower()
|
||||
# Texture conversion counters are tracked separately; don't let generic
|
||||
# step parsing overwrite the primary install counter.
|
||||
if 'converting textures' in line_lower and 'installing files' not in line_lower:
|
||||
return None
|
||||
|
||||
match = self.wabbajack_status_pattern.search(line)
|
||||
if match:
|
||||
current = int(match.group(1))
|
||||
|
||||
@@ -20,8 +20,13 @@ class ProgressParserPhaseMixin:
|
||||
phase = self._map_section_to_phase(section_name)
|
||||
return (phase, section_match.group(1).strip())
|
||||
|
||||
# [FILE_PROGRESS] lines drive file activity only — skip phase extraction for them
|
||||
if '[FILE_PROGRESS]' in line:
|
||||
return None
|
||||
|
||||
# Make the [timestamp] prefix optional — engine no longer emits it.
|
||||
action_match = re.search(
|
||||
r'\[.*?\]\s*(Installing|Downloading|Extracting|Validating|Processing|Checking existing)',
|
||||
r'(?:\[.*?\]\s*)?(Installing|Downloading|Extracting|Validating|Processing|Checking existing)',
|
||||
line,
|
||||
re.IGNORECASE
|
||||
)
|
||||
@@ -51,13 +56,18 @@ class ProgressParserPhaseMixin:
|
||||
return InstallationPhase.DOWNLOAD
|
||||
elif 'extract' in section_lower:
|
||||
return InstallationPhase.EXTRACT
|
||||
elif 'validate' in section_lower or 'verif' in section_lower:
|
||||
elif 'hash' in section_lower or 'validate' in section_lower or 'verif' in section_lower:
|
||||
return InstallationPhase.VALIDATE
|
||||
elif 'install' in section_lower:
|
||||
return InstallationPhase.INSTALL
|
||||
elif 'bsa' in section_lower or 'building' 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:
|
||||
elif ('configur' in section_lower or 'initializ' in section_lower
|
||||
or 'looking' in section_lower or 'cleaning' in section_lower
|
||||
or 'unmodified' in section_lower or 'updating' in section_lower
|
||||
or 'folder' in section_lower or 'delete' in section_lower):
|
||||
return InstallationPhase.INITIALIZATION
|
||||
else:
|
||||
return InstallationPhase.UNKNOWN
|
||||
|
||||
@@ -94,9 +94,6 @@ class ProgressStateProcessingMixin:
|
||||
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
|
||||
@@ -109,6 +106,9 @@ class ProgressStateProcessingMixin:
|
||||
self.state.bsa_building_total = bsa_total
|
||||
updated = True
|
||||
|
||||
if hasattr(parsed.file_progress, '_hidden') and parsed.file_progress._hidden:
|
||||
return updated
|
||||
|
||||
if parsed.file_progress.filename.lower().endswith('.wabbajack'):
|
||||
self._wabbajack_entry_name = parsed.file_progress.filename
|
||||
self._remove_synthetic_wabbajack()
|
||||
|
||||
@@ -153,7 +153,7 @@ class ShortcutLaunchOptionsMixin:
|
||||
content = (
|
||||
"[handlers]\n"
|
||||
"size=1\n"
|
||||
"1\\games=\"skyrimse,skyrim\"\n"
|
||||
"1\\games=\"skyrimse,skyrim,fallout4,falloutnv,fallout3,oblivion,enderal,starfield\"\n"
|
||||
f"1\\executable={win_path}\n"
|
||||
"1\\arguments=\n"
|
||||
)
|
||||
|
||||
@@ -145,7 +145,7 @@ def increase_file_descriptor_limit(target_limit=1048576):
|
||||
# Get current limit for reporting
|
||||
try:
|
||||
soft_limit, _ = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||
except:
|
||||
except (OSError, ValueError):
|
||||
soft_limit = "unknown"
|
||||
|
||||
return False, soft_limit, soft_limit, f"Failed to increase file descriptor limit: {e}"
|
||||
@@ -154,7 +154,7 @@ class ProcessManager:
|
||||
"""
|
||||
Shared process manager for robust subprocess launching, tracking, and cancellation.
|
||||
"""
|
||||
def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0):
|
||||
def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False):
|
||||
self.cmd = cmd
|
||||
# Default to cleaned environment if None to prevent AppImage variable inheritance
|
||||
if env is None:
|
||||
@@ -164,15 +164,17 @@ class ProcessManager:
|
||||
self.cwd = cwd
|
||||
self.text = text
|
||||
self.bufsize = bufsize
|
||||
self.separate_stderr = separate_stderr
|
||||
self.proc = None
|
||||
self.process_group_pid = None
|
||||
self._start_process()
|
||||
|
||||
def _start_process(self):
|
||||
stderr_arg = subprocess.PIPE if self.separate_stderr else subprocess.STDOUT
|
||||
self.proc = subprocess.Popen(
|
||||
self.cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
stderr=stderr_arg,
|
||||
env=self.env,
|
||||
cwd=self.cwd,
|
||||
text=self.text,
|
||||
@@ -186,38 +188,48 @@ class ProcessManager:
|
||||
Attempt to robustly terminate the process and its children.
|
||||
"""
|
||||
cleanup_attempts = 0
|
||||
if self.proc:
|
||||
try:
|
||||
self.proc.terminate()
|
||||
try:
|
||||
if self.proc:
|
||||
try:
|
||||
self.proc.wait(timeout=timeout_terminate)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.proc.kill()
|
||||
try:
|
||||
self.proc.wait(timeout=timeout_kill)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
# Kill process group if possible
|
||||
if self.process_group_pid:
|
||||
try:
|
||||
os.killpg(self.process_group_pid, signal.SIGKILL)
|
||||
self.proc.terminate()
|
||||
try:
|
||||
self.proc.wait(timeout=timeout_terminate)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
# Last resort: pkill by command name
|
||||
while cleanup_attempts < max_cleanup_attempts:
|
||||
try:
|
||||
subprocess.run(['pkill', '-f', os.path.basename(self.cmd[0])], timeout=5, capture_output=True)
|
||||
self.proc.kill()
|
||||
try:
|
||||
self.proc.wait(timeout=timeout_kill)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
cleanup_attempts += 1
|
||||
# Kill entire process group (catches 7zz and other child processes)
|
||||
if self.process_group_pid:
|
||||
try:
|
||||
os.killpg(self.process_group_pid, signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
# Last resort: pkill by command name
|
||||
while cleanup_attempts < max_cleanup_attempts:
|
||||
try:
|
||||
subprocess.run(['pkill', '-f', os.path.basename(self.cmd[0])], timeout=5, capture_output=True)
|
||||
except Exception:
|
||||
pass
|
||||
cleanup_attempts += 1
|
||||
finally:
|
||||
# Always close pipes — unblocks threads blocked on read(1) or iterating stderr
|
||||
if self.proc:
|
||||
for pipe in (self.proc.stdout, self.proc.stderr):
|
||||
if pipe:
|
||||
try:
|
||||
pipe.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def is_running(self):
|
||||
return self.proc and self.proc.poll() is None
|
||||
@@ -234,5 +246,8 @@ class ProcessManager:
|
||||
|
||||
def read_stdout_char(self):
|
||||
if self.proc and self.proc.stdout:
|
||||
return self.proc.stdout.read(1)
|
||||
return None
|
||||
try:
|
||||
return self.proc.stdout.read(1)
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
return None
|
||||
@@ -229,7 +229,7 @@ class TTWInstallerBackendMixin:
|
||||
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:
|
||||
def integrate_ttw_into_modlist(ttw_output_path: Path, modlist_install_dir: Path, ttw_version: str, skip_copy: bool = False) -> bool:
|
||||
"""Integrate TTW output into a modlist's MO2 structure."""
|
||||
import shutil
|
||||
logging_handler = LoggingHandler()
|
||||
@@ -246,12 +246,16 @@ class TTWInstallerBackendMixin:
|
||||
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")
|
||||
if skip_copy:
|
||||
# TTW was installed directly to target_mod_dir — no copy needed
|
||||
logger.info("TTW already at target location, skipping copy: %s", target_mod_dir)
|
||||
else:
|
||||
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"
|
||||
|
||||
@@ -36,7 +36,7 @@ class WabbajackParser:
|
||||
|
||||
# List of supported games in Jackify
|
||||
self.supported_games = [
|
||||
'skyrim', 'fallout4', 'falloutnv', 'oblivion',
|
||||
'skyrim', 'fallout4', 'falloutnv', 'fallout3', 'oblivion',
|
||||
'starfield', 'oblivion_remastered', 'enderal'
|
||||
]
|
||||
|
||||
|
||||
@@ -218,7 +218,9 @@ class WineUtilsProtonMixin:
|
||||
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"
|
||||
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d/GE-Proton",
|
||||
Path("/usr/share/steam/compatibilitytools.d"),
|
||||
Path("/usr/lib/steam/compatibilitytools.d"),
|
||||
]
|
||||
return [path for path in compat_paths if path.exists()]
|
||||
|
||||
@@ -357,7 +359,7 @@ class WineUtilsProtonMixin:
|
||||
if version_match:
|
||||
major_ver = int(version_match.group(1))
|
||||
minor_ver = int(version_match.group(2))
|
||||
priority = 200 + (major_ver * 10) + minor_ver
|
||||
priority = 200 + (major_ver * 10) + minor_ver # kept for backward compat; sort uses tuple key
|
||||
compat_name = WineUtilsProtonMixin._parse_compat_tool_name(proton_dir) or dir_name
|
||||
found_versions.append({
|
||||
'name': dir_name,
|
||||
@@ -374,7 +376,7 @@ class WineUtilsProtonMixin:
|
||||
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)
|
||||
found_versions.sort(key=lambda x: (x['major_version'], x['minor_version']), reverse=True)
|
||||
logger.info(f"Found {len(found_versions)} GE-Proton version(s)")
|
||||
return found_versions
|
||||
|
||||
@@ -427,7 +429,16 @@ class WineUtilsProtonMixin:
|
||||
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)
|
||||
_TYPE_RANK = {'GE-Proton': 2, 'ThirdParty-Proton': 1, 'Valve-Proton': 0}
|
||||
all_versions.sort(
|
||||
key=lambda x: (
|
||||
_TYPE_RANK.get(x.get('type', ''), 0),
|
||||
x.get('major_version', 0),
|
||||
x.get('minor_version', 0),
|
||||
x.get('priority', 0),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
unique_versions = []
|
||||
seen_names = set()
|
||||
for version in all_versions:
|
||||
@@ -443,17 +454,16 @@ class WineUtilsProtonMixin:
|
||||
|
||||
@staticmethod
|
||||
def select_best_proton() -> Optional[Dict[str, Any]]:
|
||||
"""Select the best available Proton (GE or Valve). Excludes third-party builds."""
|
||||
"""Select the best available Proton version. Prefers GE-Proton, then Valve, then any third-party build."""
|
||||
available_versions = WineUtilsProtonMixin.scan_all_proton_versions()
|
||||
if not available_versions:
|
||||
logger.warning("No compatible Proton versions found")
|
||||
logger.warning("No 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']})")
|
||||
best_version = available_versions[0]
|
||||
if best_version.get('type') == 'ThirdParty-Proton':
|
||||
logger.debug(f"No GE/Valve Proton found; using third-party build: {best_version['name']}")
|
||||
else:
|
||||
logger.info(f"Selected Proton: {best_version['name']} ({best_version['type']})")
|
||||
return best_version
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -83,7 +83,7 @@ class WinetricksEnvMixin:
|
||||
self.logger.warning(f"User-selected Proton no longer exists: {user_proton_path}")
|
||||
|
||||
if not wine_binary:
|
||||
if user_proton_path == 'auto':
|
||||
if not user_proton_path or user_proton_path == 'auto':
|
||||
self.logger.info("Auto-detecting Proton (user selected 'auto')")
|
||||
best_proton = WineUtils.select_best_proton()
|
||||
if best_proton:
|
||||
|
||||
@@ -8,250 +8,9 @@ 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.
|
||||
|
||||
@@ -151,6 +151,7 @@ class GameUtilsMixin:
|
||||
game_dir_names = {
|
||||
"skyrim": "Skyrim Special Edition",
|
||||
"fnv": "FalloutNV",
|
||||
"fo3": "Fallout3",
|
||||
"fo4": "Fallout4",
|
||||
"oblivion": "Oblivion",
|
||||
"oblivion_remastered": "Oblivion Remastered",
|
||||
|
||||
@@ -7,15 +7,6 @@ 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."""
|
||||
|
||||
@@ -112,7 +103,7 @@ class ProtonOperationsMixin:
|
||||
# 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")
|
||||
logger.debug(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:
|
||||
"""
|
||||
@@ -165,7 +156,7 @@ class ProtonOperationsMixin:
|
||||
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")
|
||||
logger.debug(f"[DEBUG] Set Proton version {proton_version} for AppID {appid} in config.vdf")
|
||||
|
||||
# Small delay to ensure filesystem write completes
|
||||
import time
|
||||
@@ -175,7 +166,7 @@ class ProtonOperationsMixin:
|
||||
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}")
|
||||
logger.debug(f"[DEBUG] Verification: AppID {appid} -> {compat_mapping}")
|
||||
|
||||
return True
|
||||
|
||||
@@ -324,14 +315,14 @@ class ProtonOperationsMixin:
|
||||
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}")
|
||||
logger.debug(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}")
|
||||
logger.debug(f"[DEBUG] Set compatibility tool STL-style: AppID {unsigned_appid} -> {compat_tool}")
|
||||
|
||||
return True
|
||||
|
||||
@@ -564,7 +555,7 @@ class ProtonOperationsMixin:
|
||||
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")
|
||||
logger.debug(f"[DEBUG] Updated localconfig.vdf: Signed AppID {signed_appid_int} -> OverlayAppEnable=1, DisableLaunchInVR=1")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -72,7 +72,12 @@ class RegistryOperationsMixin:
|
||||
return False
|
||||
|
||||
def _apply_universal_dotnet_fixes(self, modlist_compatdata_path: str):
|
||||
"""Apply universal dotnet4.x compatibility registry fixes to ALL modlists"""
|
||||
"""Apply universal dotnet4.x compatibility registry fixes to ALL modlists.
|
||||
|
||||
Direct file editing is preferred over `wine reg add` — faster, no Wine
|
||||
process overhead, and works even when Proton isn't on PATH. Falls back
|
||||
to subprocess wine reg add when the reg files haven't been created yet.
|
||||
"""
|
||||
try:
|
||||
prefix_path = os.path.join(modlist_compatdata_path, "pfx")
|
||||
if not os.path.exists(prefix_path):
|
||||
@@ -81,59 +86,99 @@ class RegistryOperationsMixin:
|
||||
|
||||
logger.info("Applying universal dotnet4.x compatibility registry fixes...")
|
||||
|
||||
# Find the appropriate Wine binary to use for registry operations
|
||||
user_reg = os.path.join(prefix_path, "user.reg")
|
||||
system_reg = os.path.join(prefix_path, "system.reg")
|
||||
|
||||
fix1 = fix2 = False
|
||||
|
||||
if os.path.exists(user_reg):
|
||||
fix1 = self._reg_set_value(
|
||||
user_reg,
|
||||
"[Software\\\\Wine\\\\DllOverrides]",
|
||||
'"*mscoree"',
|
||||
'"native"',
|
||||
)
|
||||
if os.path.exists(system_reg):
|
||||
fix2 = self._reg_set_value(
|
||||
system_reg,
|
||||
"[Software\\\\Microsoft\\\\.NETFramework]",
|
||||
'"OnlyUseLatestCLR"',
|
||||
"dword:00000001",
|
||||
)
|
||||
|
||||
if fix1 and fix2:
|
||||
logger.info("Universal dotnet4.x compatibility fixes applied via direct reg file editing")
|
||||
return True
|
||||
|
||||
# Fall back to wine reg add when reg files are not present yet
|
||||
logger.debug("Reg files not ready; falling back to wine reg add")
|
||||
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")
|
||||
logger.error("Could not find Wine binary for registry fallback")
|
||||
return False
|
||||
|
||||
# Set environment for Wine registry operations
|
||||
env = os.environ.copy()
|
||||
env['WINEPREFIX'] = prefix_path
|
||||
env['WINEDEBUG'] = '-all' # Suppress Wine debug output
|
||||
env['WINEDEBUG'] = '-all'
|
||||
|
||||
# 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'
|
||||
]
|
||||
r1 = subprocess.run(
|
||||
[wine_binary, 'reg', 'add',
|
||||
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
|
||||
'/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'],
|
||||
env=env, capture_output=True, text=True, errors='replace',
|
||||
)
|
||||
r2 = subprocess.run(
|
||||
[wine_binary, 'reg', 'add',
|
||||
'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework',
|
||||
'/v', 'OnlyUseLatestCLR', '/t', 'REG_DWORD', '/d', '1', '/f'],
|
||||
env=env, capture_output=True, text=True, errors='replace',
|
||||
)
|
||||
|
||||
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")
|
||||
ok = r1.returncode == 0 and r2.returncode == 0
|
||||
if ok:
|
||||
logger.info("Universal dotnet4.x fixes applied via wine reg add fallback")
|
||||
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
|
||||
logger.warning("Some dotnet4.x registry fixes failed")
|
||||
return ok
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply universal dotnet4.x fixes: {e}")
|
||||
return False
|
||||
|
||||
def _reg_set_value(self, reg_path: str, section: str, key: str, value: str) -> bool:
|
||||
"""Set or add a key=value pair in a Wine .reg text file."""
|
||||
try:
|
||||
with open(reg_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
in_section = False
|
||||
updated = False
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped.lower() == section.lower():
|
||||
in_section = True
|
||||
elif stripped.startswith('[') and in_section:
|
||||
# Reached next section without finding key; insert before it
|
||||
lines.insert(i, f'{key}={value}\n')
|
||||
updated = True
|
||||
break
|
||||
elif in_section and stripped.startswith(key.lower()) or (in_section and stripped.lower().startswith(key.lower())):
|
||||
lines[i] = f'{key}={value}\n'
|
||||
updated = True
|
||||
break
|
||||
|
||||
if not updated:
|
||||
if not in_section:
|
||||
lines.append(f'\n{section}\n')
|
||||
lines.append(f'{key}={value}\n')
|
||||
|
||||
with open(reg_path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"_reg_set_value failed for {reg_path}: {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:
|
||||
@@ -227,8 +272,41 @@ class RegistryOperationsMixin:
|
||||
logger.debug(f"Error during recursive wine search in {proton_path}: {e}")
|
||||
return None
|
||||
|
||||
def _create_canonical_game_symlink(self, pfx_path: Path, real_game_path: str) -> bool:
|
||||
"""Symlink the real game dir into the prefix at the canonical Windows Steam path.
|
||||
|
||||
The Bethesda launcher validates that Installed Path looks like a proper
|
||||
Windows Steam path (C:\\Program Files...). A raw Z:\\ or D:\\ path passes
|
||||
the existence check on the user's own machine but fails for other users
|
||||
whose Wine path translation differs. By symlinking the real directory into
|
||||
drive_c/Program Files (x86)/Steam/steamapps/common/, we write a canonical
|
||||
C:\\ path to the registry that satisfies the launcher, while NVSE follows
|
||||
the symlink to reach the actual executable.
|
||||
"""
|
||||
try:
|
||||
real_path = Path(real_game_path)
|
||||
game_dir_name = real_path.name
|
||||
|
||||
symlink_parent = pfx_path / "drive_c" / "Program Files (x86)" / "Steam" / "steamapps" / "common"
|
||||
symlink_parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
symlink_path = symlink_parent / game_dir_name
|
||||
|
||||
if symlink_path.is_symlink():
|
||||
symlink_path.unlink()
|
||||
elif symlink_path.exists():
|
||||
logger.warning(f"Real directory already exists at symlink target {symlink_path}, skipping")
|
||||
return False
|
||||
|
||||
symlink_path.symlink_to(real_path)
|
||||
logger.info(f"Created game symlink: {symlink_path} -> {real_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create canonical game symlink: {e}")
|
||||
return False
|
||||
|
||||
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"""
|
||||
"""Detect and inject FNV/FO3/Enderal game paths into the modlist prefix registry."""
|
||||
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")
|
||||
@@ -236,41 +314,74 @@ class RegistryOperationsMixin:
|
||||
|
||||
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"
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
"22300": { # Fallout 3 AppID
|
||||
"name": "Fallout 3",
|
||||
"common_names": ["Fallout 3", "Fallout3", "Fallout 3 GOTY"],
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]",
|
||||
"path_key": "Installed Path",
|
||||
},
|
||||
"22370": { # Fallout 3 GOTY AppID alias
|
||||
"name": "Fallout 3",
|
||||
"common_names": ["Fallout 3 GOTY", "Fallout 3"],
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]",
|
||||
"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"
|
||||
}
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\SureAI\\\\Enderal SE]",
|
||||
"path_key": "installed path",
|
||||
},
|
||||
}
|
||||
|
||||
# Detect and inject each game
|
||||
|
||||
pfx_path = Path(modlist_compatdata_path) / "pfx"
|
||||
|
||||
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}")
|
||||
if not game_path:
|
||||
logger.debug(f"{config['name']} not found in Steam libraries")
|
||||
continue
|
||||
|
||||
logger.info(f"Detected {config['name']} at: {game_path}")
|
||||
|
||||
# Create a symlink inside the prefix at the canonical Windows Steam path so the
|
||||
# Bethesda launcher sees a proper C:\ path while NVSE can still resolve the exe.
|
||||
symlink_ok = self._create_canonical_game_symlink(pfx_path, game_path)
|
||||
|
||||
if symlink_ok:
|
||||
game_dir_name = Path(game_path).name
|
||||
canonical_win_path = f"C:\\Program Files (x86)\\Steam\\steamapps\\common\\{game_dir_name}"
|
||||
wine_val = canonical_win_path.replace("\\", "\\\\") + "\\\\"
|
||||
success = self._reg_set_value(
|
||||
system_reg_path,
|
||||
config["registry_section"],
|
||||
f'"{config["path_key"]}"',
|
||||
f'"{wine_val}"',
|
||||
)
|
||||
if success:
|
||||
logger.info(f"Registry set to canonical path for {config['name']}: {canonical_win_path}")
|
||||
else:
|
||||
logger.warning(f"Failed to set canonical registry path for {config['name']}")
|
||||
else:
|
||||
# Symlink failed — fall back to writing the real Z:/D: path
|
||||
logger.warning(f"Symlink failed for {config['name']}, writing real path to registry")
|
||||
success = self._update_registry_path(
|
||||
system_reg_path,
|
||||
config["registry_section"],
|
||||
config["registry_section"],
|
||||
config["path_key"],
|
||||
game_path
|
||||
)
|
||||
if success:
|
||||
logger.info(f"Updated registry entry for {config['name']}")
|
||||
logger.info(f"Updated registry entry for {config['name']} (real path fallback)")
|
||||
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")
|
||||
|
||||
|
||||
@@ -16,13 +16,6 @@ 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)
|
||||
|
||||
from .automated_prefix_shortcuts import ShortcutOperationsMixin
|
||||
from .automated_prefix_proton import ProtonOperationsMixin
|
||||
from .automated_prefix_creation import PrefixCreationMixin
|
||||
@@ -170,7 +163,6 @@ exit"""
|
||||
logger.error(f"Error getting config path: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def kill_running_processes(self) -> bool:
|
||||
"""
|
||||
Kill any running processes that might interfere with prefix creation.
|
||||
|
||||
@@ -11,15 +11,6 @@ from .automated_prefix_shortcuts_cleanup import AutomatedPrefixShortcutsCleanupM
|
||||
|
||||
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."""
|
||||
|
||||
@@ -148,10 +139,10 @@ class ShortcutOperationsMixin(AutomatedPrefixShortcutsCleanupMixin):
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
debug_print(f"[DEBUG] create_shortcut_directly called for '{shortcut_name}' - this is the fallback method")
|
||||
logger.debug(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")
|
||||
logger.debug("[DEBUG] No shortcuts path found")
|
||||
return False
|
||||
|
||||
# Read current shortcuts
|
||||
@@ -207,191 +198,6 @@ class ShortcutOperationsMixin(AutomatedPrefixShortcutsCleanupMixin):
|
||||
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:
|
||||
"""
|
||||
|
||||
@@ -3,21 +3,10 @@ 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."""
|
||||
|
||||
@@ -110,166 +99,6 @@ class WorkflowMixin:
|
||||
|
||||
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]]:
|
||||
@@ -323,9 +152,9 @@ class WorkflowMixin:
|
||||
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
|
||||
# No launch options needed - FNV, FO3 and Enderal use registry injection
|
||||
custom_launch_options = None
|
||||
if special_game_type in ["fnv", "enderal"]:
|
||||
if special_game_type in ["fnv", "fo3", "enderal"]:
|
||||
logger.info(f"Using registry injection approach for {special_game_type.upper()} modlist")
|
||||
else:
|
||||
logger.debug("Standard modlist - no special game handling needed")
|
||||
@@ -372,7 +201,8 @@ class WorkflowMixin:
|
||||
)
|
||||
if not success:
|
||||
logger.error("Failed to create shortcut with native Steam service")
|
||||
return False, None, None, None
|
||||
from jackify.shared.errors import shortcut_write_failed
|
||||
raise shortcut_write_failed("create_shortcut_with_native_service returned failure")
|
||||
|
||||
logger.info(f"Step 1 completed: Shortcut created with native service, AppID: {appid}")
|
||||
if progress_callback:
|
||||
@@ -398,7 +228,8 @@ class WorkflowMixin:
|
||||
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
|
||||
from jackify.shared.errors import steam_restart_failed
|
||||
raise steam_restart_failed("Steam did not come back within the expected time")
|
||||
|
||||
logger.info("Step 2 completed: Steam started")
|
||||
if progress_callback:
|
||||
@@ -415,7 +246,8 @@ class WorkflowMixin:
|
||||
|
||||
if not self.create_prefix_with_proton_wrapper(appid):
|
||||
logger.error("Failed to create Proton prefix")
|
||||
return False, None, None, None
|
||||
from jackify.shared.errors import prefix_creation_failed
|
||||
raise prefix_creation_failed("create_prefix_with_proton_wrapper returned failure")
|
||||
|
||||
logger.info("Step 3 completed: Proton prefix created")
|
||||
if progress_callback:
|
||||
@@ -437,7 +269,7 @@ class WorkflowMixin:
|
||||
# Get prefix path (needed for logging regardless of game type)
|
||||
prefix_path = self.get_prefix_path(appid)
|
||||
|
||||
if special_game_type in ["fnv", "enderal"]:
|
||||
if special_game_type in ["fnv", "fo3", "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...")
|
||||
@@ -448,8 +280,6 @@ class WorkflowMixin:
|
||||
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")
|
||||
@@ -477,10 +307,13 @@ class WorkflowMixin:
|
||||
return True, prefix_path, appid, last_timestamp
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in working workflow: {e}")
|
||||
logger.error(f"Error in working workflow: {e}", exc_info=True)
|
||||
if progress_callback:
|
||||
progress_callback(f"Error: {str(e)}")
|
||||
return False, None, None, None
|
||||
from jackify.shared.errors import JackifyError, prefix_creation_failed
|
||||
if isinstance(e, JackifyError):
|
||||
raise
|
||||
raise prefix_creation_failed(str(e)) from e
|
||||
|
||||
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]]:
|
||||
@@ -520,7 +353,8 @@ class WorkflowMixin:
|
||||
|
||||
if not self.create_prefix_with_proton_wrapper(appid):
|
||||
logger.error("Failed to create Proton prefix")
|
||||
return False, None, None, None
|
||||
from jackify.shared.errors import prefix_creation_failed
|
||||
raise prefix_creation_failed("create_prefix_with_proton_wrapper returned failure")
|
||||
|
||||
logger.info("Step 3 completed: Proton prefix created")
|
||||
if progress_callback:
|
||||
|
||||
166
jackify/backend/services/mo2_setup_service.py
Normal file
166
jackify/backend/services/mo2_setup_service.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
MO2 Setup Service
|
||||
|
||||
Downloads and configures a standalone Mod Organizer 2 instance:
|
||||
- Fetches latest release from GitHub
|
||||
- Extracts with 7z
|
||||
- Creates a Steam shortcut and Proton prefix via AutomatedPrefixService
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_dangerous_path(path: Path) -> bool:
|
||||
home = Path.home().resolve()
|
||||
dangerous = [Path('/'), Path('/home'), Path('/root'), home]
|
||||
return any(path.resolve() == d for d in dangerous)
|
||||
|
||||
|
||||
class MO2SetupService:
|
||||
"""Download, extract, and configure a standalone MO2 instance."""
|
||||
|
||||
GITHUB_API = "https://api.github.com/repos/ModOrganizer2/modorganizer/releases/latest"
|
||||
ASSET_PATTERN = re.compile(r"Mod\.Organizer-\d+\.\d+(\.\d+)?\.7z$")
|
||||
|
||||
def setup_mo2(
|
||||
self,
|
||||
install_dir: Path,
|
||||
shortcut_name: str = "Mod Organizer 2",
|
||||
progress_callback: Optional[Callable[[str], None]] = None,
|
||||
should_cancel: Optional[Callable[[], bool]] = None,
|
||||
) -> Tuple[bool, Optional[int], Optional[str]]:
|
||||
"""
|
||||
Download, extract, and configure MO2.
|
||||
|
||||
Returns (success, app_id, error_message).
|
||||
"""
|
||||
|
||||
def _progress(msg: str):
|
||||
logger.info(msg)
|
||||
if progress_callback:
|
||||
progress_callback(msg)
|
||||
|
||||
def _cancel_requested() -> bool:
|
||||
try:
|
||||
return bool(should_cancel and should_cancel())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if not shutil.which('7z'):
|
||||
return False, None, "7z not found. Install p7zip-full (or equivalent) first."
|
||||
|
||||
if _is_dangerous_path(install_dir):
|
||||
return False, None, f"Refusing to install to dangerous path: {install_dir}"
|
||||
|
||||
# Create directory
|
||||
try:
|
||||
install_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
return False, None, f"Could not create directory: {e}"
|
||||
|
||||
# Fetch release info
|
||||
_progress("Fetching latest MO2 release info...")
|
||||
if _cancel_requested():
|
||||
return False, None, "MO2 setup cancelled."
|
||||
try:
|
||||
resp = requests.get(self.GITHUB_API, timeout=15, verify=True)
|
||||
resp.raise_for_status()
|
||||
release = resp.json()
|
||||
except Exception as e:
|
||||
return False, None, f"Failed to fetch MO2 release info: {e}"
|
||||
|
||||
# Find asset
|
||||
asset = None
|
||||
for a in release.get('assets', []):
|
||||
if self.ASSET_PATTERN.match(a['name']):
|
||||
asset = a
|
||||
break
|
||||
if not asset:
|
||||
return False, None, "Could not find main MO2 .7z asset in latest release."
|
||||
|
||||
# Download
|
||||
archive_path = install_dir / asset['name']
|
||||
_progress(f"Downloading {asset['name']}...")
|
||||
if _cancel_requested():
|
||||
return False, None, "MO2 setup cancelled."
|
||||
try:
|
||||
with requests.get(asset['browser_download_url'], stream=True, timeout=120, verify=True) as r:
|
||||
r.raise_for_status()
|
||||
with open(archive_path, 'wb') as f:
|
||||
for chunk in r.iter_content(chunk_size=8192):
|
||||
if _cancel_requested():
|
||||
return False, None, "MO2 setup cancelled."
|
||||
f.write(chunk)
|
||||
except Exception as e:
|
||||
return False, None, f"Failed to download MO2 archive: {e}"
|
||||
|
||||
# Extract
|
||||
_progress(f"Extracting to {install_dir}...")
|
||||
if _cancel_requested():
|
||||
return False, None, "MO2 setup cancelled."
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['7z', 'x', str(archive_path), f'-o{install_dir}'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=1200,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
err = result.stderr.decode(errors='ignore')
|
||||
return False, None, f"Extraction failed: {err}"
|
||||
except Exception as e:
|
||||
return False, None, f"Extraction failed: {e}"
|
||||
|
||||
# Validate
|
||||
mo2_exe = install_dir / "ModOrganizer.exe"
|
||||
if not mo2_exe.exists():
|
||||
# MO2 release archives usually extract into a single top-level folder.
|
||||
# Limit search depth to direct children to avoid expensive recursive scans.
|
||||
mo2_exe = None
|
||||
for child in install_dir.iterdir():
|
||||
candidate = child / "ModOrganizer.exe"
|
||||
if candidate.exists():
|
||||
mo2_exe = candidate
|
||||
break
|
||||
if not mo2_exe:
|
||||
return False, None, "ModOrganizer.exe not found after extraction."
|
||||
|
||||
# Cleanup archive
|
||||
try:
|
||||
archive_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_progress(f"MO2 installed at: {mo2_exe.parent}")
|
||||
|
||||
# Set up Steam shortcut and Proton prefix
|
||||
_progress("Creating Steam shortcut and Proton prefix...")
|
||||
if _cancel_requested():
|
||||
return False, None, "MO2 setup cancelled."
|
||||
try:
|
||||
from .automated_prefix_service import AutomatedPrefixService
|
||||
svc = AutomatedPrefixService()
|
||||
success, prefix_path, app_id, _last_ts = svc.run_working_workflow(
|
||||
shortcut_name=shortcut_name,
|
||||
modlist_install_dir=str(install_dir),
|
||||
final_exe_path=str(mo2_exe),
|
||||
progress_callback=_progress,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"AutomatedPrefixService failed: {e}")
|
||||
return False, None, f"Prefix setup failed: {e}"
|
||||
|
||||
if not success:
|
||||
return False, None, "Failed to create Steam shortcut or Proton prefix."
|
||||
|
||||
_progress("MO2 setup complete.")
|
||||
return True, app_id, None
|
||||
@@ -246,6 +246,7 @@ class ModlistService(ModlistServiceInstallationMixin):
|
||||
'appid': getattr(context, 'app_id', None), # Use updated app_id from Steam
|
||||
'engine_installed': getattr(context, 'engine_installed', False), # Path manipulation flag
|
||||
'download_dir': str(context.download_dir) if getattr(context, 'download_dir', None) else None,
|
||||
'modlist_source': getattr(context, 'modlist_source', None),
|
||||
}
|
||||
|
||||
debug_callback(f"Configuration context built: {config_context}")
|
||||
@@ -479,4 +480,4 @@ class ModlistService(ModlistServiceInstallationMixin):
|
||||
logger.error("Game type is required")
|
||||
return False
|
||||
|
||||
return True
|
||||
return True
|
||||
|
||||
98
jackify/backend/services/nexus_premium_service.py
Normal file
98
jackify/backend/services/nexus_premium_service.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Nexus Premium status detection service."""
|
||||
import time
|
||||
import logging
|
||||
from typing import Tuple, Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NEXUS_VALIDATE_URL = "https://api.nexusmods.com/v1/users/validate.json"
|
||||
NEXUS_OAUTH_USERINFO_URL = "https://users.nexusmods.com/oauth/userinfo"
|
||||
_CACHE_TTL_SECONDS = 3600
|
||||
|
||||
|
||||
class NexusPremiumService:
|
||||
"""Check and cache Nexus Premium status for the authenticated user."""
|
||||
|
||||
def check_premium_status(
|
||||
self, auth_token: str, is_oauth: bool = False
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Query Nexus API for premium status.
|
||||
|
||||
Args:
|
||||
auth_token: Nexus API key or OAuth access token.
|
||||
is_oauth: True when auth_token is an OAuth Bearer token.
|
||||
|
||||
Returns:
|
||||
(is_premium, username) — both None/False on failure.
|
||||
"""
|
||||
cached = self._read_cache(auth_token, is_oauth=is_oauth)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
result = self._fetch(auth_token, is_oauth=is_oauth)
|
||||
if result[1] is not None:
|
||||
self._write_cache(auth_token, result, is_oauth=is_oauth)
|
||||
return result
|
||||
|
||||
def _fetch(self, auth_token: str, is_oauth: bool = False) -> Tuple[bool, Optional[str]]:
|
||||
try:
|
||||
if is_oauth:
|
||||
# OAuth path: userinfo endpoint returns membership_roles array.
|
||||
# The validate endpoint is for API keys only.
|
||||
resp = requests.get(
|
||||
NEXUS_OAUTH_USERINFO_URL,
|
||||
headers={"Authorization": f"Bearer {auth_token}", "Accept": "application/json"},
|
||||
timeout=8,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
roles = data.get("membership_roles") or []
|
||||
is_premium = "premium" in roles
|
||||
username = data.get("name") or data.get("sub")
|
||||
else:
|
||||
resp = requests.get(
|
||||
NEXUS_VALIDATE_URL,
|
||||
headers={"apikey": auth_token, "Accept": "application/json"},
|
||||
timeout=8,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
is_premium = bool(data.get("is_premium") or data.get("is_supporter"))
|
||||
username = data.get("name")
|
||||
logger.debug(f"Nexus user: {username}, premium={is_premium}, roles={data.get('membership_roles')}")
|
||||
return is_premium, username
|
||||
except Exception as e:
|
||||
logger.debug(f"Nexus premium check failed: {e}")
|
||||
return False, None
|
||||
|
||||
def _cache_key(self, token: str, is_oauth: bool = False) -> str:
|
||||
suffix = "oauth" if is_oauth else "apikey"
|
||||
return f"nexus_premium_cache_{token[:8]}_{suffix}"
|
||||
|
||||
def _read_cache(self, token: str, is_oauth: bool = False) -> Optional[Tuple[bool, Optional[str]]]:
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
cfg = ConfigHandler()
|
||||
entry = cfg.get(self._cache_key(token, is_oauth))
|
||||
if not entry:
|
||||
return None
|
||||
if time.time() - entry.get("ts", 0) > _CACHE_TTL_SECONDS:
|
||||
return None
|
||||
return entry["is_premium"], entry.get("username")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _write_cache(self, token: str, result: Tuple[bool, Optional[str]], is_oauth: bool = False) -> None:
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
cfg = ConfigHandler()
|
||||
cfg.set(self._cache_key(token, is_oauth), {
|
||||
"is_premium": result[0],
|
||||
"username": result[1],
|
||||
"ts": time.time(),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
@@ -200,6 +200,52 @@ def is_flatpak_steam() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def ensure_flatpak_steam_filesystem_access(path: "Path") -> bool:
|
||||
"""Grant Flatpak Steam filesystem access to the parent of the given path.
|
||||
|
||||
Safe to call on non-Flatpak systems — returns True immediately.
|
||||
Skips if the path is already covered by an existing override.
|
||||
Returns True if access was already present or successfully granted, False on error.
|
||||
"""
|
||||
from pathlib import Path as _Path
|
||||
if not is_flatpak_steam():
|
||||
return True
|
||||
flatpak_cmd = _get_flatpak_command()
|
||||
if not flatpak_cmd:
|
||||
logger.warning("Flatpak Steam detected but flatpak command not found — cannot grant filesystem access")
|
||||
return False
|
||||
grant_path = str(_Path(path).parent)
|
||||
env = _get_clean_subprocess_env()
|
||||
try:
|
||||
# Check existing overrides to avoid redundant changes
|
||||
result = subprocess.run(
|
||||
[flatpak_cmd, "override", "--user", "--show", "com.valvesoftware.Steam"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
text=True, timeout=10, env=env,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.splitlines():
|
||||
if "filesystems" in line.lower() and grant_path in line:
|
||||
logger.debug(f"Flatpak Steam already has access to {grant_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not check existing Flatpak overrides: {e}")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[flatpak_cmd, "override", "--user", f"--filesystem={grant_path}", "com.valvesoftware.Steam"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
||||
text=True, timeout=15, env=env,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info(f"Granted Flatpak Steam filesystem access to {grant_path}")
|
||||
return True
|
||||
logger.warning(f"flatpak override failed (exit {result.returncode}): {result.stderr.strip()}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to grant Flatpak Steam filesystem access: {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
|
||||
|
||||
@@ -31,6 +31,7 @@ class UpdateInfo:
|
||||
release_date: str
|
||||
changelog: str
|
||||
download_url: str
|
||||
source: str = "github"
|
||||
file_size: Optional[int] = None
|
||||
is_critical: bool = False
|
||||
is_delta_update: bool = False
|
||||
@@ -100,9 +101,17 @@ class UpdateService:
|
||||
break
|
||||
|
||||
if download_url:
|
||||
# Prefer Nexus CDN for Premium users when release embeds nexus_file_id
|
||||
release_body = release_data.get('body', '')
|
||||
nexus_url = self._try_nexus_download_url(release_body)
|
||||
update_source = "github"
|
||||
if nexus_url:
|
||||
download_url = nexus_url
|
||||
update_source = "nexus"
|
||||
|
||||
# Determine if this is a delta update
|
||||
is_delta = '.delta' in download_url or 'delta' in download_url.lower()
|
||||
|
||||
|
||||
# Safety checks to prevent segfault
|
||||
try:
|
||||
# Sanitize string fields
|
||||
@@ -111,9 +120,9 @@ class UpdateService:
|
||||
safe_date = str(release_data.get('published_at', ''))
|
||||
safe_changelog = str(release_data.get('body', ''))[:1000] # Limit size
|
||||
safe_url = str(download_url)
|
||||
|
||||
|
||||
logger.debug(f"Creating UpdateInfo for version {safe_version}")
|
||||
|
||||
|
||||
update_info = UpdateInfo(
|
||||
version=safe_version,
|
||||
tag_name=safe_tag,
|
||||
@@ -121,7 +130,8 @@ class UpdateService:
|
||||
changelog=safe_changelog,
|
||||
download_url=safe_url,
|
||||
file_size=file_size,
|
||||
is_delta_update=is_delta
|
||||
is_delta_update=is_delta,
|
||||
source=update_source,
|
||||
)
|
||||
|
||||
logger.debug(f"UpdateInfo created successfully")
|
||||
@@ -142,6 +152,56 @@ class UpdateService:
|
||||
logger.error(f"Unexpected error checking for updates: {e}")
|
||||
return None
|
||||
|
||||
def _try_nexus_download_url(self, release_body: str) -> Optional[str]:
|
||||
"""
|
||||
If the user is Nexus Premium and the release body embeds nexus_file_id,
|
||||
return a Nexus CDN download URL. Returns None on any failure.
|
||||
|
||||
Release body format expected:
|
||||
nexus_mod_id: 12345
|
||||
nexus_file_id: 67890
|
||||
"""
|
||||
import re
|
||||
try:
|
||||
mod_match = re.search(r'nexus_mod_id:\s*(\d+)', release_body, re.IGNORECASE)
|
||||
file_match = re.search(r'nexus_file_id:\s*(\d+)', release_body, re.IGNORECASE)
|
||||
if not file_match:
|
||||
return None
|
||||
nexus_file_id = int(file_match.group(1))
|
||||
nexus_mod_id = int(mod_match.group(1)) if mod_match else None
|
||||
|
||||
from jackify.backend.services.nexus_auth_service import NexusAuthService
|
||||
auth_service = NexusAuthService()
|
||||
token = auth_service.get_auth_token()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
from jackify.backend.services.nexus_premium_service import NexusPremiumService
|
||||
is_premium, _ = NexusPremiumService().check_premium_status(token)
|
||||
if not is_premium:
|
||||
logger.debug("Nexus download skipped: user is not Premium")
|
||||
return None
|
||||
|
||||
if nexus_mod_id is None:
|
||||
return None
|
||||
|
||||
api_url = f"https://api.nexusmods.com/v1/games/site/mods/{nexus_mod_id}/files/{nexus_file_id}/download_link.json"
|
||||
resp = requests.get(
|
||||
api_url,
|
||||
headers={"apikey": token, "Accept": "application/json"},
|
||||
timeout=8,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
links = resp.json()
|
||||
if isinstance(links, list) and links:
|
||||
cdn_url = links[0].get("URI")
|
||||
if cdn_url:
|
||||
logger.debug(f"Using Nexus CDN URL for update")
|
||||
return cdn_url
|
||||
except Exception as e:
|
||||
logger.debug(f"Nexus download URL lookup failed: {e}")
|
||||
return None
|
||||
|
||||
def _is_newer_version(self, version: str) -> bool:
|
||||
"""
|
||||
Compare versions to determine if update is newer.
|
||||
|
||||
@@ -114,7 +114,7 @@ def should_offer_vnv_automation(modlist_name: str, modlist_install_location: Opt
|
||||
def run_vnv_automation_if_applicable(
|
||||
modlist_name: str,
|
||||
modlist_install_location: Path,
|
||||
game_root: Path,
|
||||
game_root: Optional[Path],
|
||||
ttw_installer_path: Optional[Path] = None,
|
||||
progress_callback: Optional[Callable[[str], None]] = None,
|
||||
manual_file_callback: Optional[Callable[[str, str], Optional[Path]]] = None,
|
||||
@@ -144,10 +144,27 @@ def run_vnv_automation_if_applicable(
|
||||
|
||||
logger.info(f"VNV detected: {modlist_name}")
|
||||
|
||||
# Resolve game root for Fallout New Vegas if caller didn't provide one.
|
||||
# CLI flows may pass None and rely on auto-detection.
|
||||
resolved_game_root = game_root
|
||||
if resolved_game_root is None:
|
||||
try:
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
game_paths = PathHandler().find_vanilla_game_paths()
|
||||
resolved_game_root = game_paths.get('Fallout New Vegas')
|
||||
except Exception as detect_err:
|
||||
logger.debug(f"VNV game root auto-detection failed: {detect_err}")
|
||||
|
||||
if resolved_game_root is None:
|
||||
logger.warning("VNV detected but Fallout New Vegas game root could not be resolved")
|
||||
if progress_callback:
|
||||
progress_callback("VNV automation skipped: Fallout New Vegas path not found")
|
||||
return False, None
|
||||
|
||||
# Initialize service
|
||||
vnv_service = VNVPostInstallService(
|
||||
modlist_install_location=modlist_install_location,
|
||||
game_root=game_root,
|
||||
game_root=resolved_game_root,
|
||||
ttw_installer_path=ttw_installer_path
|
||||
)
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ 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
|
||||
start_steam, is_flatpak_steam, is_steam_deck, _get_clean_subprocess_env, robust_steam_restart,
|
||||
ensure_flatpak_steam_filesystem_access,
|
||||
)
|
||||
from .automated_prefix_service import AutomatedPrefixService
|
||||
|
||||
@@ -48,8 +49,14 @@ class WabbajackInstallerService:
|
||||
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
|
||||
best = WineUtils.select_best_proton()
|
||||
if best:
|
||||
return Path(best['path']), best['steam_compat_name']
|
||||
valve = WineUtils.select_best_valve_proton()
|
||||
if valve:
|
||||
return Path(valve['path']), valve.get('steam_compat_name', 'proton_experimental')
|
||||
logger.error("No Proton version found")
|
||||
return None, None
|
||||
|
||||
def install_wabbajack(
|
||||
self,
|
||||
@@ -93,6 +100,9 @@ class WabbajackInstallerService:
|
||||
_is_steam_deck = is_steam_deck()
|
||||
_is_flatpak = is_flatpak_steam()
|
||||
|
||||
if _is_flatpak:
|
||||
ensure_flatpak_steam_filesystem_access(install_folder)
|
||||
|
||||
try:
|
||||
# Step 1: Check requirements
|
||||
update_progress("Checking requirements...", 1, 5)
|
||||
|
||||
142
jackify/backend/utils/engine_error_parser.py
Normal file
142
jackify/backend/utils/engine_error_parser.py
Normal file
@@ -0,0 +1,142 @@
|
||||
import json
|
||||
from typing import Optional
|
||||
from jackify.shared.errors import (
|
||||
JackifyError, InstallError, OAuthError,
|
||||
oauth_expired, wabbajack_install_failed, format_technical_context,
|
||||
)
|
||||
|
||||
|
||||
def _ctx_detail(ctx: dict) -> Optional[str]:
|
||||
if not ctx:
|
||||
return None
|
||||
return format_technical_context(context=ctx)
|
||||
|
||||
|
||||
_TYPE_MAP = {
|
||||
"auth_failed": lambda msg, ctx: oauth_expired(),
|
||||
"premium_required": lambda msg, ctx: InstallError(
|
||||
"Nexus Premium Required",
|
||||
msg,
|
||||
suggestion="Jackify requires a Nexus Premium account for automated installs.",
|
||||
solutions=[
|
||||
"Log in to Nexus Mods with a Premium account.",
|
||||
"Non-premium support is planned for a future release.",
|
||||
],
|
||||
),
|
||||
"network_error": lambda msg, ctx: InstallError(
|
||||
"Network or Download Failure",
|
||||
msg,
|
||||
suggestion="Check your internet connection and retry.",
|
||||
solutions=[
|
||||
"Verify your internet connection.",
|
||||
"Re-run the install — Wabbajack resumes from where it stopped.",
|
||||
"Check if Nexus Mods is reachable at nexusmods.com.",
|
||||
"Disable VPN or proxy if active.",
|
||||
],
|
||||
technical=_ctx_detail(ctx),
|
||||
),
|
||||
"disk_full": lambda msg, ctx: InstallError(
|
||||
"Disk Full",
|
||||
msg,
|
||||
suggestion="Free space on the target drive and retry.",
|
||||
solutions=[
|
||||
"Run: df -h to see available space.",
|
||||
"Delete old modlist downloads or backups.",
|
||||
"Move the install to a larger drive.",
|
||||
],
|
||||
technical=_ctx_detail(ctx),
|
||||
),
|
||||
"permission_denied": lambda msg, ctx: InstallError(
|
||||
"Permission Denied",
|
||||
msg,
|
||||
suggestion="Check write permissions on the target path.",
|
||||
solutions=[
|
||||
"Ensure Jackify and Steam are run as the same user.",
|
||||
"Avoid install paths under /usr, /var, or /opt.",
|
||||
f"Check permissions: ls -la {ctx.get('path', '<path>')}",
|
||||
],
|
||||
technical=_ctx_detail(ctx),
|
||||
),
|
||||
"archive_corrupt": lambda msg, ctx: InstallError(
|
||||
"Corrupted Archive",
|
||||
msg,
|
||||
suggestion="Re-run the install — Wabbajack will re-download and re-verify the file.",
|
||||
solutions=[
|
||||
"Re-run the install.",
|
||||
"Check available disk space (partial downloads appear corrupt).",
|
||||
"Check Modlist_Install_workflow.log for the specific filename.",
|
||||
],
|
||||
technical=_ctx_detail(ctx),
|
||||
),
|
||||
"file_not_found": lambda msg, ctx: InstallError(
|
||||
"File Not Found",
|
||||
msg,
|
||||
suggestion="Check the modlist URL and your game installation paths.",
|
||||
solutions=[
|
||||
"Verify the modlist name is correct.",
|
||||
"Ensure the target game is installed.",
|
||||
"Re-run — the modlist index may have been temporarily unavailable.",
|
||||
],
|
||||
technical=_ctx_detail(ctx),
|
||||
),
|
||||
"validation_failed": lambda msg, ctx: InstallError(
|
||||
"Validation Failed",
|
||||
msg,
|
||||
suggestion="Re-run the install to re-download any failed files.",
|
||||
solutions=[
|
||||
"Re-run the install — Wabbajack resumes and re-validates.",
|
||||
"Check available disk space.",
|
||||
"Check Modlist_Install_workflow.log for specific failures.",
|
||||
],
|
||||
technical=_ctx_detail(ctx),
|
||||
),
|
||||
"download_stalled": lambda msg, ctx: InstallError(
|
||||
"Downloads Stalled",
|
||||
msg,
|
||||
suggestion="Check your connection and OAuth status, then retry.",
|
||||
solutions=[
|
||||
"Check your internet connection.",
|
||||
"In Settings, confirm Nexus OAuth is active.",
|
||||
"Re-run the install.",
|
||||
],
|
||||
),
|
||||
}
|
||||
|
||||
_EXIT_CODE_MAP = {
|
||||
2: lambda d, c: _TYPE_MAP["auth_failed"](d, c or {}),
|
||||
3: lambda d, c: _TYPE_MAP["network_error"](d, c or {}),
|
||||
4: lambda d, c: _TYPE_MAP["disk_full"](d, c or {}),
|
||||
5: lambda d, c: _TYPE_MAP["validation_failed"](d, c or {}),
|
||||
6: lambda d, c: wabbajack_install_failed(format_technical_context(detail=d, context=c) or d),
|
||||
}
|
||||
|
||||
|
||||
def parse_engine_error_line(line: str) -> Optional[JackifyError]:
|
||||
"""Parse one stderr line. Returns JackifyError or None."""
|
||||
line = line.strip()
|
||||
if not line:
|
||||
return None
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
if obj.get("je") != "1":
|
||||
return None
|
||||
if obj.get("level") == "warning":
|
||||
return None
|
||||
error_type = obj.get("type", "engine_error")
|
||||
message = obj.get("message", "An unknown engine error occurred.")
|
||||
context = obj.get("context") or {}
|
||||
factory = _TYPE_MAP.get(error_type)
|
||||
if factory:
|
||||
return factory(message, context)
|
||||
return wabbajack_install_failed(f"[{error_type}] {message}")
|
||||
|
||||
|
||||
def error_from_exit_code(exit_code: int, detail: str = "", context: Optional[dict] = None) -> Optional[JackifyError]:
|
||||
"""Return a JackifyError based on exit code alone (fallback when no stderr line received)."""
|
||||
factory = _EXIT_CODE_MAP.get(exit_code)
|
||||
if factory:
|
||||
detail_message = detail or f"Engine exited with code {exit_code}."
|
||||
return factory(detail_message, context or {})
|
||||
return None
|
||||
87
jackify/backend/utils/modlist_meta.py
Normal file
87
jackify/backend/utils/modlist_meta.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
JACKIFY_META_FILE = "jackify_meta.json"
|
||||
|
||||
_BYTEARRAY_RE = re.compile(r"@ByteArray\((.+)\)", re.DOTALL)
|
||||
|
||||
|
||||
def write_modlist_meta(
|
||||
install_dir: str,
|
||||
modlist_name: str,
|
||||
game_type: Optional[str],
|
||||
install_mode: str = "online",
|
||||
modlist_version: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Write jackify_meta.json into install_dir. Returns True on success."""
|
||||
from jackify import __version__ as jackify_version
|
||||
import datetime
|
||||
|
||||
try:
|
||||
meta = {
|
||||
"modlist_name": modlist_name,
|
||||
"game_type": game_type or "",
|
||||
"install_mode": install_mode,
|
||||
"install_date": datetime.datetime.now().isoformat(timespec="seconds"),
|
||||
"jackify_version": jackify_version,
|
||||
}
|
||||
if modlist_version:
|
||||
meta["modlist_version"] = modlist_version
|
||||
|
||||
out = Path(install_dir) / JACKIFY_META_FILE
|
||||
out.write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
logger.debug(f"Wrote modlist meta to {out}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to write modlist meta: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def read_modlist_meta(install_dir: str) -> Optional[dict]:
|
||||
"""Read jackify_meta.json from install_dir. Returns dict or None."""
|
||||
try:
|
||||
meta_path = Path(install_dir) / JACKIFY_META_FILE
|
||||
if not meta_path.exists():
|
||||
return None
|
||||
return json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to read modlist meta from {install_dir}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _read_selected_profile(install_dir: str) -> Optional[str]:
|
||||
"""Read selected_profile from ModOrganizer.ini, stripping @ByteArray() wrapper."""
|
||||
try:
|
||||
mo2_ini = Path(install_dir) / "ModOrganizer.ini"
|
||||
if not mo2_ini.exists():
|
||||
return None
|
||||
for line in mo2_ini.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
if not line.startswith("selected_profile"):
|
||||
continue
|
||||
_, _, value = line.partition("=")
|
||||
value = value.strip()
|
||||
m = _BYTEARRAY_RE.match(value)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
return value or None
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to read selected_profile from {install_dir}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_modlist_name(install_dir: str) -> Optional[str]:
|
||||
"""Return the best available modlist name for install_dir.
|
||||
|
||||
Priority:
|
||||
1. jackify_meta.json (written by Jackify at install time)
|
||||
2. selected_profile from ModOrganizer.ini (set by modlist author)
|
||||
"""
|
||||
meta = read_modlist_meta(install_dir)
|
||||
if meta and meta.get("modlist_name"):
|
||||
return meta["modlist_name"]
|
||||
return _read_selected_profile(install_dir)
|
||||
Reference in New Issue
Block a user