mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-07 21:27:45 +02:00
Sync from development - prepare for v0.4.0
This commit is contained in:
39
CHANGELOG.md
39
CHANGELOG.md
@@ -1,5 +1,42 @@
|
||||
# Jackify Changelog
|
||||
|
||||
## v0.4.0 - Error Handling Rewrite
|
||||
**Release Date:** 2026-02-25
|
||||
|
||||
### New Features
|
||||
- Structured error handling across GUI and CLI with typed `JackifyError` dialogs (clear message, suggested action, numbered recovery steps, optional technical detail).
|
||||
- Structured engine error receiver: stderr JSON errors are parsed and mapped to user-facing error types, with exit-code fallback.
|
||||
- Nexus account tier indicator in Settings OAuth (`[Premium]` / `[Free]`) with cached status checks.
|
||||
- Modlist metadata support via `.jackify_meta.json`, written after install and used by configure workflows.
|
||||
- TTW eligibility workflow expanded:
|
||||
- Configure New / Configure Existing can trigger TTW workflow when eligible.
|
||||
- CLI `configure-modlist` now prompts TTW when eligible.
|
||||
- FO3 support in configure workflows, including prefix/registry handling.
|
||||
- Standalone MO2 setup in Additional Tasks (GUI and CLI).
|
||||
|
||||
### Bug Fixes
|
||||
- Proton auto-detection reliability improved, including GE-Proton ranking and fallback behavior.
|
||||
- Added detection support for system-packaged Proton layouts (Issue #162).
|
||||
- Download stall false positives reduced by checking byte advancement instead of speed readout alone.
|
||||
- Flatpak Steam access handling improved with install-directory override support.
|
||||
- TTW installer output directory is pre-populated to the modlist location.
|
||||
- Unknown game fallback behavior improved so Wine component installation can continue where appropriate.
|
||||
|
||||
### Improvements
|
||||
- GUI debug log naming standardised to `jackify-debug.log`.
|
||||
- Error reporting/logging flow cleaned up to improve user facing info and hopefully ease support.
|
||||
- "Lazy" GUI screen initialization (main menu first, other screens on demand).
|
||||
- Proton handling improved with Valve Proton fallback when GE-Proton is unavailable.
|
||||
- FNV/FO3/Enderal registry injection now attempts canonical `C:\Program Files (x86)\Steam\steamapps\common\<Game>` paths via in-prefix symlink, with fallback to real `Z:/D:` paths if symlink creation fails. Looking forward to feedback on this one if anyone still has their FNV launcher only show "Install" instead of "Play".
|
||||
|
||||
### Engine Updates
|
||||
- jackify-engine updated to `0.4.8`.
|
||||
- Archive download progress improvements (remaining size + ETA).
|
||||
- Download speed reporting reliability improvements on Linux.
|
||||
- ZIP extraction fixes for Cyrillic filenames.
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0 - Codebase Refactoring
|
||||
**Release Date:** 2026-02-06
|
||||
|
||||
@@ -1200,4 +1237,4 @@ This release completes the logging refactor that was blocking development workfl
|
||||
- Modular handler architecture for extensibility.
|
||||
|
||||
## v0.0.09 and Earlier
|
||||
See commit history for previous versions.
|
||||
See commit history for previous versions.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 405 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
|
||||
Wabbajack modlists natively on Linux systems.
|
||||
"""
|
||||
|
||||
__version__ = "0.3.0"
|
||||
__version__ = "0.4.0"
|
||||
|
||||
@@ -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)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user