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

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