Sync from development - prepare for v0.4.0

This commit is contained in:
Omni
2026-02-25 17:40:43 +00:00
parent 2eb54b9a36
commit 805718222a
324 changed files with 4914 additions and 4567 deletions

View File

@@ -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}")

View File

@@ -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")

View File

@@ -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

View File

@@ -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'],

View File

@@ -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"""

View File

@@ -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}")

View File

@@ -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

View File

@@ -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"

View File

@@ -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}")

View File

@@ -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}")

View File

@@ -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():

View File

@@ -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

View File

@@ -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)

View File

@@ -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))

View File

@@ -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

View File

@@ -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()

View File

@@ -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"
)

View File

@@ -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

View File

@@ -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"

View File

@@ -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'
]

View File

@@ -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

View File

@@ -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:

View File

@@ -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.

View File

@@ -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",

View File

@@ -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

View File

@@ -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")

View File

@@ -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.

View File

@@ -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:
"""

View File

@@ -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:

View 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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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.

View File

@@ -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
)

View File

@@ -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)

View 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

View 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)