mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 02:17:45 +02:00
Sync from development - prepare for v0.4.0
This commit is contained in:
@@ -92,7 +92,7 @@ class EnginePerformanceMonitor:
|
||||
# Also monitor the parent Python process for comparison
|
||||
try:
|
||||
self._parent_process = psutil.Process(os.getpid())
|
||||
except:
|
||||
except Exception:
|
||||
self._parent_process = None
|
||||
|
||||
self._monitoring = True
|
||||
@@ -220,7 +220,7 @@ class EnginePerformanceMonitor:
|
||||
parent_cpu_percent = self._parent_process.cpu_percent()
|
||||
parent_memory_info = self._parent_process.memory_info()
|
||||
parent_memory_mb = parent_memory_info.rss / (1024 * 1024)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get I/O info
|
||||
|
||||
@@ -15,6 +15,7 @@ class GameDetector:
|
||||
'skyrim': ['Skyrim Special Edition', 'Skyrim'],
|
||||
'fallout4': ['Fallout 4'],
|
||||
'falloutnv': ['Fallout New Vegas'],
|
||||
'fallout3': ['Fallout 3'],
|
||||
'oblivion': ['Oblivion'],
|
||||
'starfield': ['Starfield'],
|
||||
'oblivion_remastered': ['Oblivion Remastered']
|
||||
@@ -34,6 +35,8 @@ class GameDetector:
|
||||
return 'fallout4'
|
||||
elif any(keyword in modlist_lower for keyword in ['fallout new vegas', 'fonv', 'fnv', 'new vegas', 'nvse']):
|
||||
return 'falloutnv'
|
||||
elif any(keyword in modlist_lower for keyword in ['fallout 3', 'fo3', 'fallout3', 'fose']):
|
||||
return 'fallout3'
|
||||
elif any(keyword in modlist_lower for keyword in ['oblivion', 'obse', 'shivering isles']):
|
||||
return 'oblivion'
|
||||
elif any(keyword in modlist_lower for keyword in ['starfield', 'sf', 'starfieldse']):
|
||||
@@ -108,6 +111,12 @@ class GameDetector:
|
||||
'required_dlc': [],
|
||||
'compatibility_tools': ['protontricks', 'winetricks']
|
||||
},
|
||||
'fallout3': {
|
||||
'launcher': 'FOSE',
|
||||
'min_proton_version': '5.0',
|
||||
'required_dlc': [],
|
||||
'compatibility_tools': ['protontricks', 'winetricks']
|
||||
},
|
||||
'oblivion': {
|
||||
'launcher': 'OBSE',
|
||||
'min_proton_version': '5.0',
|
||||
@@ -173,6 +182,7 @@ class GameDetector:
|
||||
'skyrim': 'SKSE',
|
||||
'fallout4': 'F4SE',
|
||||
'falloutnv': 'NVSE',
|
||||
'fallout3': 'FOSE',
|
||||
'oblivion': 'OBSE',
|
||||
'starfield': 'SFSE',
|
||||
'oblivion_remastered': 'OBSE'
|
||||
@@ -205,6 +215,7 @@ class GameDetector:
|
||||
'skyrim': ['vcrun2019', 'dotnet48', 'dxvk'],
|
||||
'fallout4': ['vcrun2019', 'dotnet48', 'dxvk'],
|
||||
'falloutnv': ['vcrun2019', 'dotnet48'],
|
||||
'fallout3': ['vcrun2019', 'dotnet48'],
|
||||
'oblivion': ['vcrun2019', 'dotnet48'],
|
||||
'starfield': ['vcrun2022', 'dotnet6', 'dotnet7', 'dxvk'],
|
||||
'oblivion_remastered': ['vcrun2022', 'dotnet6', 'dotnet7', 'dxvk']
|
||||
@@ -222,6 +233,7 @@ class GameDetector:
|
||||
'skyrim': ['SkyrimSE.exe', 'Skyrim.exe'],
|
||||
'fallout4': ['Fallout4.exe'],
|
||||
'falloutnv': ['FalloutNV.exe'],
|
||||
'fallout3': ['Fallout3.exe'],
|
||||
'oblivion': ['Oblivion.exe']
|
||||
}
|
||||
|
||||
@@ -250,6 +262,11 @@ class GameDetector:
|
||||
'config_dirs': ['Data', 'Saves'],
|
||||
'registry_keys': ['HKEY_LOCAL_MACHINE\\SOFTWARE\\Bethesda Softworks\\FalloutNV']
|
||||
},
|
||||
'fallout3': {
|
||||
'ini_files': ['Fallout.ini', 'FalloutPrefs.ini'],
|
||||
'config_dirs': ['Data', 'Saves'],
|
||||
'registry_keys': ['HKEY_LOCAL_MACHINE\\SOFTWARE\\Bethesda Softworks\\Fallout3']
|
||||
},
|
||||
'oblivion': {
|
||||
'ini_files': ['Oblivion.ini'],
|
||||
'config_dirs': ['Data', 'Saves'],
|
||||
|
||||
@@ -32,7 +32,6 @@ from .resolution_handler import ResolutionHandler
|
||||
from .protontricks_handler import ProtontricksHandler
|
||||
from .path_handler import PathHandler
|
||||
from .vdf_handler import VDFHandler
|
||||
from .mo2_handler import MO2Handler
|
||||
from jackify.shared.ui_utils import print_section_header
|
||||
from .completers import path_completer
|
||||
|
||||
@@ -72,7 +71,6 @@ class MenuHandler:
|
||||
steamdeck=self.config_handler.settings.get('steamdeck', False),
|
||||
verbose=False
|
||||
)
|
||||
self.mo2_handler = MO2Handler(self)
|
||||
|
||||
def display_banner(self):
|
||||
"""Display the application banner - DEPRECATED: Banner display should be handled by frontend"""
|
||||
|
||||
@@ -82,22 +82,6 @@ class ModlistMenuHandler:
|
||||
print("\nInvalid selection. Please try again.")
|
||||
input("\nPress Enter to continue...")
|
||||
|
||||
def _display_manual_proton_steps(self, modlist_name):
|
||||
"""Displays the detailed manual steps required for Proton setup."""
|
||||
# Keep these as print for clear user instructions
|
||||
print(f"\n{COLOR_PROMPT}--- Manual Proton Setup Required ---{COLOR_RESET}")
|
||||
print("Please complete the following steps in Steam:")
|
||||
print(f" 1. Locate the '{COLOR_INFO}{modlist_name}{COLOR_RESET}' entry in your Steam Library")
|
||||
print(" 2. Right-click and select 'Properties'")
|
||||
print(" 3. Switch to the 'Compatibility' tab")
|
||||
print(" 4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'")
|
||||
print(" 5. Select 'Proton - Experimental' from the dropdown menu")
|
||||
print(" 6. Close the Properties window")
|
||||
print(f" 7. Launch '{COLOR_INFO}{modlist_name}{COLOR_RESET}' from your Steam Library")
|
||||
print(" 8. If Mod Organizer opens or produces any error message, that's normal")
|
||||
print(" 9. No matter what,CLOSE Mod Organizer completely and return here")
|
||||
print(f"{COLOR_PROMPT}------------------------------------{COLOR_RESET}")
|
||||
|
||||
def _get_mo2_path(self) -> Optional[str]:
|
||||
"""
|
||||
Get the path to ModOrganizer.exe from user input.
|
||||
@@ -269,6 +253,16 @@ class ModlistMenuHandler:
|
||||
|
||||
# Use automated prefix service for modern workflow
|
||||
print(f"\n{COLOR_INFO}Using automated Steam setup workflow...{COLOR_RESET}")
|
||||
|
||||
# CLI safety warning: this workflow will restart Steam as part of shortcut/prefix setup.
|
||||
print("\n" + "-" * 28)
|
||||
print(
|
||||
f"{COLOR_PROMPT}Configure New Modlist will restart Steam and close any running game.{COLOR_RESET}"
|
||||
)
|
||||
continue_choice = input(f"{COLOR_PROMPT}Continue with Configure New now? (Y/n): {COLOR_RESET}").strip().lower()
|
||||
if continue_choice == 'n':
|
||||
print(f"{COLOR_INFO}Configuration cancelled before Steam restart.{COLOR_RESET}")
|
||||
return True
|
||||
|
||||
from ..services.automated_prefix_service import AutomatedPrefixService
|
||||
prefix_service = AutomatedPrefixService()
|
||||
@@ -441,7 +435,15 @@ class ModlistMenuHandler:
|
||||
Shared configuration phase for both new and existing modlists.
|
||||
Expects context dict with keys: name, appid, path (at minimum).
|
||||
"""
|
||||
import os
|
||||
self.logger.debug(f"[DEBUG] Entering run_modlist_configuration_phase with context: {context}")
|
||||
# Write nxmhandler.ini to suppress MO2's NXM Handling popup on first launch.
|
||||
# This must happen before MO2 runs for the first time, so do it here rather than
|
||||
# relying on callers to remember.
|
||||
_mo2_exe = context.get('mo2_exe_path') or os.path.join(context.get('path', ''), 'ModOrganizer.exe')
|
||||
_mo2_dir = os.path.dirname(_mo2_exe)
|
||||
if _mo2_dir and os.path.isdir(_mo2_dir):
|
||||
self.shortcut_handler.write_nxmhandler_ini(_mo2_dir, _mo2_exe)
|
||||
# Robust AppID lookup for GUI/CLI: if appid missing but mo2_exe_path present, look it up
|
||||
if 'appid' not in context or not context.get('appid'):
|
||||
if 'mo2_exe_path' in context and context['mo2_exe_path']:
|
||||
@@ -454,7 +456,6 @@ class ModlistMenuHandler:
|
||||
self.logger.debug(f"[DEBUG] set_modlist returned: {set_modlist_result}")
|
||||
|
||||
# Check GUI mode early to avoid input() calls in GUI context
|
||||
import os
|
||||
gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
|
||||
|
||||
if not set_modlist_result:
|
||||
@@ -501,10 +502,22 @@ class ModlistMenuHandler:
|
||||
self.logger.info(f"Starting configuration steps for {context.get('name')}")
|
||||
print() # Add padding before status line
|
||||
status_line = ""
|
||||
import os
|
||||
gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
|
||||
def update_status(msg):
|
||||
nonlocal status_line
|
||||
filtered_prefixes = (
|
||||
"Using bundled tools directory (after system PATH):",
|
||||
"Bundled tools available:",
|
||||
)
|
||||
msg_lc = msg.lower().strip()
|
||||
if msg.startswith(filtered_prefixes):
|
||||
return
|
||||
# Suppress per-tool dependency detail lines like:
|
||||
# " wget: /usr/bin/wget (system)" / " 7z: ... (bundled)".
|
||||
if msg.startswith(" ") and (
|
||||
"(system)" in msg_lc or "(bundled)" in msg_lc or "not found" in msg_lc
|
||||
):
|
||||
return
|
||||
if status_line:
|
||||
print("\r" + " " * len(status_line), end="\r")
|
||||
if gui_mode:
|
||||
@@ -526,28 +539,29 @@ class ModlistMenuHandler:
|
||||
if status_line:
|
||||
print()
|
||||
|
||||
# Configure ENB for Linux compatibility (non-blocking, same as GUI)
|
||||
# Configure ENB for Linux compatibility (non-blocking).
|
||||
# In GUI mode, modlist_service.py handles ENB after this function returns,
|
||||
# so skip here to avoid running it twice.
|
||||
enb_detected = False
|
||||
try:
|
||||
from ..handlers.enb_handler import ENBHandler
|
||||
from pathlib import Path
|
||||
|
||||
enb_handler = ENBHandler()
|
||||
install_dir = Path(context.get('path', ''))
|
||||
|
||||
if install_dir.exists():
|
||||
enb_success, enb_message, enb_detected = enb_handler.configure_enb_for_linux(install_dir)
|
||||
|
||||
if enb_message:
|
||||
if enb_success:
|
||||
self.logger.info(enb_message)
|
||||
update_status(enb_message)
|
||||
else:
|
||||
self.logger.warning(enb_message)
|
||||
# Non-blocking: continue workflow even if ENB config fails
|
||||
except Exception as e:
|
||||
self.logger.warning(f"ENB configuration skipped due to error: {e}")
|
||||
# Continue workflow - ENB config is optional
|
||||
if not gui_mode:
|
||||
try:
|
||||
from ..handlers.enb_handler import ENBHandler
|
||||
from pathlib import Path
|
||||
|
||||
enb_handler = ENBHandler()
|
||||
install_dir = Path(context.get('path', ''))
|
||||
|
||||
if install_dir.exists():
|
||||
enb_success, enb_message, enb_detected = enb_handler.configure_enb_for_linux(install_dir)
|
||||
|
||||
if enb_message:
|
||||
if enb_success:
|
||||
self.logger.info(enb_message)
|
||||
update_status(enb_message)
|
||||
else:
|
||||
self.logger.warning(enb_message)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"ENB configuration skipped due to error: {e}")
|
||||
|
||||
# Run modlist-specific post-install automation (e.g., VNV) before showing completion
|
||||
# Only in CLI mode - GUI handles this in install_modlist.py
|
||||
@@ -560,17 +574,37 @@ class ModlistMenuHandler:
|
||||
modlist_path = Path(context.get('path', ''))
|
||||
|
||||
try:
|
||||
print("")
|
||||
print("Running VNV post-install automation...")
|
||||
def _confirm_vnv(description: str) -> bool:
|
||||
print(f"\n{description}\n")
|
||||
try:
|
||||
user_input = input(f"{COLOR_PROMPT}Run VNV post-install automation now? (Y/n): {COLOR_RESET}").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return False
|
||||
return user_input in ("", "y", "yes")
|
||||
|
||||
def _manual_vnv_file(title: str, instructions: str):
|
||||
print(f"\n{COLOR_WARNING}{title}{COLOR_RESET}")
|
||||
print(instructions)
|
||||
try:
|
||||
file_input = input(f"{COLOR_PROMPT}Path to downloaded file: {COLOR_RESET}").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return None
|
||||
if not file_input:
|
||||
return None
|
||||
selected = Path(file_input).expanduser().resolve()
|
||||
return selected if selected.exists() else None
|
||||
|
||||
automation_ran, error = run_vnv_automation_if_applicable(
|
||||
modlist_name=modlist_name,
|
||||
modlist_install_location=modlist_path,
|
||||
game_root=None, # Will be auto-detected
|
||||
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
|
||||
progress_callback=lambda msg: print(msg),
|
||||
manual_file_callback=None, # CLI doesn't support manual file callback yet
|
||||
confirmation_callback=None # Will use default confirmation in CLI
|
||||
manual_file_callback=_manual_vnv_file,
|
||||
confirmation_callback=_confirm_vnv
|
||||
)
|
||||
if automation_ran and not error:
|
||||
print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}")
|
||||
if error:
|
||||
print(f"{COLOR_WARNING}VNV automation encountered an error: {error}{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}")
|
||||
@@ -578,6 +612,22 @@ class ModlistMenuHandler:
|
||||
self.logger.debug(f"VNV automation check skipped: {e}")
|
||||
# Not an error - just means VNV automation wasn't applicable
|
||||
|
||||
if not gui_mode:
|
||||
try:
|
||||
from jackify.backend.handlers.modlist_install_cli_ttw import prompt_ttw_if_eligible
|
||||
|
||||
prompt_ttw_if_eligible(
|
||||
context.get('path', ''),
|
||||
context.get('name', '') or '',
|
||||
)
|
||||
except Exception as ttw_err:
|
||||
self.logger.error("TTW post-config prompt failed: %s", ttw_err, exc_info=True)
|
||||
print(f"{COLOR_WARNING}TTW integration prompt failed. Check logs for details.{COLOR_RESET}")
|
||||
|
||||
is_existing_flow = context.get("modlist_source") == "existing"
|
||||
completion_title = "Modlist Configuration complete!" if is_existing_flow else "Modlist Install and Configuration complete!"
|
||||
completion_log_file = "Configure_Existing_Modlist_workflow.log" if is_existing_flow else "Configure_New_Modlist_workflow.log"
|
||||
|
||||
print("")
|
||||
print("")
|
||||
print("") # Extra blank line before completion
|
||||
@@ -585,7 +635,7 @@ class ModlistMenuHandler:
|
||||
print("= Configuration phase complete =")
|
||||
print("=" * 35)
|
||||
print("")
|
||||
print("Modlist Install and Configuration complete!")
|
||||
print(completion_title)
|
||||
print(f"• You should now be able to Launch '{context.get('name')}' through Steam")
|
||||
print("• Congratulations and enjoy the game!")
|
||||
print("")
|
||||
@@ -608,7 +658,7 @@ class ModlistMenuHandler:
|
||||
# No ENB detected - no warning needed
|
||||
pass
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
print(f"Detailed log available at: {get_jackify_logs_dir()}/Configure_New_Modlist_workflow.log")
|
||||
print(f"Detailed log available at: {get_jackify_logs_dir()}/{completion_log_file}")
|
||||
# Only wait for input in CLI mode, not GUI mode
|
||||
if not gui_mode:
|
||||
input(f"{COLOR_PROMPT}Press Enter to return to the menu...{COLOR_RESET}")
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
import requests
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
import os
|
||||
import logging
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_SELECTION, COLOR_RESET, COLOR_INFO, COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING
|
||||
from .status_utils import show_status, clear_status
|
||||
from jackify.shared.ui_utils import print_section_header, print_subsection_header
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MO2Handler:
|
||||
"""
|
||||
Handles downloading and installing Mod Organizer 2 (MO2) using system 7z.
|
||||
"""
|
||||
def __init__(self, menu_handler):
|
||||
self.menu_handler = menu_handler
|
||||
# Import shortcut handler from menu_handler if available
|
||||
self.shortcut_handler = getattr(menu_handler, 'shortcut_handler', None)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def _is_dangerous_path(self, path: Path) -> bool:
|
||||
# Block /, /home, /root, and the user's home directory
|
||||
home = Path.home().resolve()
|
||||
dangerous = [Path('/'), Path('/home'), Path('/root'), home]
|
||||
return any(path.resolve() == d for d in dangerous)
|
||||
|
||||
def install_mo2(self):
|
||||
os.system('cls' if os.name == 'nt' else 'clear')
|
||||
# Banner display handled by frontend
|
||||
print_section_header('Mod Organizer 2 Installation')
|
||||
# 1. Check for 7z
|
||||
if not shutil.which('7z'):
|
||||
print(f"{COLOR_ERROR}[ERROR] 7z is not installed. Please install it (e.g., sudo apt install p7zip-full).{COLOR_RESET}")
|
||||
return False
|
||||
# 2. Prompt for install location
|
||||
default_dir = Path.home() / "ModOrganizer2"
|
||||
prompt = f"Enter the full path where Mod Organizer 2 should be installed (default: {default_dir}, enter 'q' to cancel)"
|
||||
install_dir = self.menu_handler.get_directory_path(
|
||||
prompt_message=prompt,
|
||||
default_path=default_dir,
|
||||
create_if_missing=False,
|
||||
no_header=True
|
||||
)
|
||||
if not install_dir:
|
||||
print(f"\n{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}\n")
|
||||
return False
|
||||
# Safety: Block dangerous paths
|
||||
if self._is_dangerous_path(install_dir):
|
||||
print(f"\n{COLOR_ERROR}Refusing to install to a dangerous directory: {install_dir}{COLOR_RESET}\n")
|
||||
return False
|
||||
# 3. Ask if user wants to add MO2 to Steam
|
||||
add_to_steam = input(f"Add Mod Organizer 2 as a custom Steam shortcut for Proton? (Y/n): ").strip().lower()
|
||||
add_to_steam = (add_to_steam == '' or add_to_steam.startswith('y'))
|
||||
shortcut_name = None
|
||||
if add_to_steam:
|
||||
shortcut_name = input(f"Enter a name for your new Steam shortcut (default: Mod Organizer 2): ").strip()
|
||||
if not shortcut_name:
|
||||
shortcut_name = "Mod Organizer 2"
|
||||
print_subsection_header('Configuration Phase')
|
||||
time.sleep(0.5)
|
||||
# 4. Create directory if needed, handle existing contents
|
||||
if not install_dir.exists():
|
||||
try:
|
||||
install_dir.mkdir(parents=True, exist_ok=True)
|
||||
show_status(f"Created directory: {install_dir}")
|
||||
except Exception as e:
|
||||
print(f"{COLOR_ERROR}[ERROR] Could not create directory: {e}{COLOR_RESET}")
|
||||
return False
|
||||
else:
|
||||
files = list(install_dir.iterdir())
|
||||
if files:
|
||||
print(f"{COLOR_WARNING}The directory '{install_dir}' is not empty.{COLOR_RESET}")
|
||||
print("Warning: This will permanently delete all files in the folder. Type 'DELETE' to confirm:")
|
||||
confirm = input("").strip()
|
||||
if confirm != 'DELETE':
|
||||
print(f"{COLOR_INFO}Cancelled by user. Please choose a different directory if you want to keep existing files.{COLOR_RESET}\n")
|
||||
return False
|
||||
for f in files:
|
||||
try:
|
||||
if f.is_dir():
|
||||
shutil.rmtree(f)
|
||||
else:
|
||||
f.unlink()
|
||||
except Exception as e:
|
||||
print(f"{COLOR_ERROR}Failed to delete {f}: {e}{COLOR_RESET}")
|
||||
show_status(f"Deleted all contents of {install_dir}")
|
||||
|
||||
# 5. Fetch latest MO2 release info from GitHub
|
||||
show_status("Fetching latest Mod Organizer 2 release info...")
|
||||
try:
|
||||
response = requests.get("https://api.github.com/repos/ModOrganizer2/modorganizer/releases/latest", timeout=15, verify=True)
|
||||
response.raise_for_status()
|
||||
release = response.json()
|
||||
except Exception as e:
|
||||
print(f"{COLOR_ERROR}[ERROR] Failed to fetch MO2 release info: {e}{COLOR_RESET}")
|
||||
return False
|
||||
|
||||
# 6. Find the correct .7z asset (exclude -pdbs, -src, etc)
|
||||
asset = None
|
||||
for a in release.get('assets', []):
|
||||
name = a['name']
|
||||
if re.match(r"Mod\.Organizer-\d+\.\d+(\.\d+)?\.7z$", name):
|
||||
asset = a
|
||||
break
|
||||
if not asset:
|
||||
print(f"{COLOR_ERROR}[ERROR] Could not find main MO2 .7z asset in latest release.{COLOR_RESET}")
|
||||
return False
|
||||
|
||||
# 7. Download the archive
|
||||
show_status(f"Downloading {asset['name']}...")
|
||||
archive_path = install_dir / asset['name']
|
||||
try:
|
||||
with requests.get(asset['browser_download_url'], stream=True, timeout=60, verify=True) as r:
|
||||
r.raise_for_status()
|
||||
with open(archive_path, 'wb') as f:
|
||||
for chunk in r.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
except Exception as e:
|
||||
print(f"{COLOR_ERROR}[ERROR] Failed to download MO2 archive: {e}{COLOR_RESET}")
|
||||
return False
|
||||
|
||||
# 8. Extract using 7z (suppress noisy output)
|
||||
show_status(f"Extracting to {install_dir}...")
|
||||
try:
|
||||
result = subprocess.run(['7z', 'x', str(archive_path), f'-o{install_dir}'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if result.returncode != 0:
|
||||
print(f"{COLOR_ERROR}[ERROR] Extraction failed: {result.stderr.decode(errors='ignore')}{COLOR_RESET}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"{COLOR_ERROR}[ERROR] Extraction failed: {e}{COLOR_RESET}")
|
||||
return False
|
||||
|
||||
# 9. Validate extraction
|
||||
mo2_exe = next(install_dir.glob('**/ModOrganizer.exe'), None)
|
||||
if not mo2_exe:
|
||||
print(f"{COLOR_ERROR}[ERROR] ModOrganizer.exe not found after extraction. Please check extraction.{COLOR_RESET}")
|
||||
return False
|
||||
else:
|
||||
show_status(f"MO2 installed at: {mo2_exe.parent}")
|
||||
|
||||
# 10. Add to Steam if requested
|
||||
if add_to_steam and self.shortcut_handler:
|
||||
show_status("Creating Steam shortcut...")
|
||||
try:
|
||||
from ..services.native_steam_service import NativeSteamService
|
||||
steam_service = NativeSteamService()
|
||||
|
||||
success, app_id = steam_service.create_shortcut_with_proton(
|
||||
app_name=shortcut_name,
|
||||
exe_path=str(mo2_exe),
|
||||
start_dir=str(mo2_exe.parent),
|
||||
launch_options="%command%",
|
||||
tags=["Jackify"],
|
||||
proton_version="proton_experimental"
|
||||
)
|
||||
if not success or not app_id:
|
||||
print(f"{COLOR_ERROR}[ERROR] Failed to create Steam shortcut.{COLOR_RESET}")
|
||||
else:
|
||||
show_status(f"Steam shortcut created for '{COLOR_INFO}{shortcut_name}{COLOR_RESET}'.")
|
||||
# Restart Steam and show manual steps (reuse logic from Configure Modlist)
|
||||
print("\n───────────────────────────────────────────────────────────────────")
|
||||
print(f"{COLOR_INFO}Important:{COLOR_RESET} Steam needs to restart to detect the new shortcut.")
|
||||
print("This process involves several manual steps after the restart.")
|
||||
restart_choice = input(f"\n{COLOR_PROMPT}Restart Steam automatically now? (Y/n): {COLOR_RESET}").strip().lower()
|
||||
if restart_choice != 'n':
|
||||
if hasattr(self.shortcut_handler, 'secure_steam_restart'):
|
||||
print("Restarting Steam...")
|
||||
self.shortcut_handler.secure_steam_restart()
|
||||
print("\nAfter restarting, you MUST perform the manual Proton setup steps:")
|
||||
print(f" 1. Locate '{COLOR_INFO}{shortcut_name}{COLOR_RESET}' in your Steam Library")
|
||||
print(" 2. Right-click and select 'Properties'")
|
||||
print(" 3. Switch to the 'Compatibility' tab")
|
||||
print(" 4. Check 'Force the use of a specific Steam Play compatibility tool'")
|
||||
print(" 5. Select 'Proton - Experimental' from the dropdown menu")
|
||||
print(" 6. Close the Properties window")
|
||||
print(f" 7. Launch '{COLOR_INFO}{shortcut_name}{COLOR_RESET}' from your Steam Library")
|
||||
print(" 8. If Mod Organizer opens or produces any error message, that's normal")
|
||||
print(" 9. CLOSE Mod Organizer completely and return here")
|
||||
print("───────────────────────────────────────────────────────────────────\n")
|
||||
except Exception as e:
|
||||
print(f"{COLOR_ERROR}[ERROR] Failed to create Steam shortcut: {e}{COLOR_RESET}")
|
||||
|
||||
print(f"{COLOR_SUCCESS}Mod Organizer 2 has been installed successfully!{COLOR_RESET}\n")
|
||||
return True
|
||||
@@ -329,6 +329,9 @@ class ModlistDetectionMixin:
|
||||
if 'nvse' in content or 'nvse_loader' in content or 'fallout new vegas' in content or 'falloutnv' in content:
|
||||
self.logger.info("Detected FNV via ModOrganizer.ini markers")
|
||||
return "fnv"
|
||||
if 'fose' in content or 'fose_loader' in content or ('fallout 3' in content and 'fallout 4' not in content):
|
||||
self.logger.info("Detected FO3 via ModOrganizer.ini markers")
|
||||
return "fo3"
|
||||
if any(pattern in content for pattern in ['enderal launcher', 'enderal.exe', 'enderal launcher.exe', 'enderalsteam']):
|
||||
self.logger.info("Detected Enderal via ModOrganizer.ini markers")
|
||||
return "enderal"
|
||||
@@ -353,6 +356,10 @@ class ModlistDetectionMixin:
|
||||
if nvse_loader.exists():
|
||||
self.logger.info(f"Detected FNV modlist: found nvse_loader.exe in '{base}'")
|
||||
return "fnv"
|
||||
fose_loader = base / "fose_loader.exe"
|
||||
if fose_loader.exists():
|
||||
self.logger.info(f"Detected FO3 modlist: found fose_loader.exe in '{base}'")
|
||||
return "fo3"
|
||||
enderal_launcher = base / "Enderal Launcher.exe"
|
||||
if enderal_launcher.exists():
|
||||
self.logger.info(f"Detected Enderal modlist: found Enderal Launcher.exe in '{base}'")
|
||||
@@ -366,6 +373,9 @@ class ModlistDetectionMixin:
|
||||
if 'fallout new vegas' in gt or gt == 'fnv':
|
||||
self.logger.info("Heuristic detection: game_var indicates FNV")
|
||||
return "fnv"
|
||||
if 'fallout 3' in gt or gt == 'fo3':
|
||||
self.logger.info("Heuristic detection: game_var indicates FO3")
|
||||
return "fo3"
|
||||
if 'enderal' in gt:
|
||||
self.logger.info("Heuristic detection: game_var indicates Enderal")
|
||||
return "enderal"
|
||||
|
||||
@@ -258,6 +258,7 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
try:
|
||||
# Read output in binary mode to properly handle carriage returns
|
||||
buffer = b''
|
||||
inline_progress_active = False
|
||||
last_progress_time = time.time()
|
||||
|
||||
while True:
|
||||
@@ -282,7 +283,16 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
continue
|
||||
# Enhance Nexus download errors with modlist context
|
||||
enhanced_line = self._enhance_nexus_error(line)
|
||||
print(enhanced_line, end='')
|
||||
clean_line = enhanced_line.rstrip('\r\n')
|
||||
if clean_line.startswith("Installing files "):
|
||||
print(f"\r{clean_line}", end='')
|
||||
sys.stdout.flush()
|
||||
inline_progress_active = True
|
||||
else:
|
||||
if inline_progress_active:
|
||||
print()
|
||||
inline_progress_active = False
|
||||
print(enhanced_line, end='')
|
||||
buffer = b''
|
||||
last_progress_time = time.time()
|
||||
elif chunk == b'\r':
|
||||
@@ -300,7 +310,15 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
continue
|
||||
# Enhance Nexus download errors with modlist context
|
||||
enhanced_line = self._enhance_nexus_error(line)
|
||||
print(enhanced_line, end='')
|
||||
clean_line = enhanced_line.rstrip('\r\n')
|
||||
if clean_line.startswith("Installing files "):
|
||||
print(f"\r{clean_line}", end='')
|
||||
inline_progress_active = True
|
||||
else:
|
||||
if inline_progress_active:
|
||||
print()
|
||||
inline_progress_active = False
|
||||
print(enhanced_line, end='')
|
||||
sys.stdout.flush()
|
||||
buffer = b''
|
||||
last_progress_time = time.time()
|
||||
@@ -314,7 +332,13 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
# Print any remaining buffer content
|
||||
if buffer:
|
||||
line = buffer.decode('utf-8', errors='replace')
|
||||
if inline_progress_active:
|
||||
print()
|
||||
inline_progress_active = False
|
||||
print(line, end='')
|
||||
|
||||
if inline_progress_active:
|
||||
print()
|
||||
|
||||
proc.wait()
|
||||
|
||||
@@ -415,7 +439,10 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
if not is_gui_mode:
|
||||
# Prompt user if they want to configure Steam shortcut now
|
||||
print("\n" + "-" * 28)
|
||||
print(f"{COLOR_PROMPT}Would you like to add '{shortcut_name}' to Steam and configure it now?{COLOR_RESET}")
|
||||
print(
|
||||
f"{COLOR_PROMPT}Would you like to add '{shortcut_name}' to Steam and configure it now? "
|
||||
f"Steam will restart and close any running game.{COLOR_RESET}"
|
||||
)
|
||||
configure_choice = input(f"{COLOR_PROMPT}Configure now? (Y/n): {COLOR_RESET}").strip().lower()
|
||||
|
||||
if configure_choice == 'n':
|
||||
@@ -424,71 +451,61 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
|
||||
# Proceed with Steam configuration
|
||||
self.logger.info(f"Starting Steam configuration for '{shortcut_name}'")
|
||||
|
||||
# Step 1: Create Steam shortcut first
|
||||
|
||||
mo2_exe_path = os.path.join(install_dir_str, 'ModOrganizer.exe')
|
||||
|
||||
# Use the working shortcut creation process from legacy code
|
||||
|
||||
from .shortcut_handler import ShortcutHandler
|
||||
shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck, verbose=False)
|
||||
|
||||
# Create nxmhandler.ini to suppress NXM popup
|
||||
shortcut_handler.write_nxmhandler_ini(install_dir_str, mo2_exe_path)
|
||||
|
||||
# Create shortcut with working NativeSteamService
|
||||
from ..services.native_steam_service import NativeSteamService
|
||||
steam_service = NativeSteamService()
|
||||
|
||||
success, app_id = steam_service.create_shortcut_with_proton(
|
||||
app_name=shortcut_name,
|
||||
exe_path=mo2_exe_path,
|
||||
start_dir=os.path.dirname(mo2_exe_path),
|
||||
launch_options="%command%",
|
||||
tags=["Jackify"],
|
||||
proton_version="proton_experimental"
|
||||
)
|
||||
|
||||
if not success or not app_id:
|
||||
self.logger.error("Failed to create Steam shortcut")
|
||||
print(f"{COLOR_ERROR}Failed to create Steam shortcut. Check logs for details.{COLOR_RESET}")
|
||||
return
|
||||
|
||||
# Step 2: Handle Steam restart and manual steps (if not in GUI mode)
|
||||
if not is_gui_mode:
|
||||
print(f"\n{COLOR_INFO}Steam shortcut created successfully!{COLOR_RESET}")
|
||||
print("Steam needs to restart to detect the new shortcut.")
|
||||
|
||||
restart_choice = input("\nRestart Steam automatically now? (Y/n): ").strip().lower()
|
||||
if restart_choice == 'n':
|
||||
print("\nPlease restart Steam manually and complete the Proton setup steps.")
|
||||
print("You can configure this modlist later using 'Configure Existing Modlist'.")
|
||||
|
||||
from ..services.automated_prefix_service import AutomatedPrefixService
|
||||
prefix_service = AutomatedPrefixService()
|
||||
|
||||
def _cli_progress(message):
|
||||
noisy_patterns = (
|
||||
"using bundled tools directory",
|
||||
"bundled tools available",
|
||||
"checking winetricks dependencies",
|
||||
"(bundled)",
|
||||
"(system)",
|
||||
"wget",
|
||||
"curl",
|
||||
"aria2c",
|
||||
"sha256sum",
|
||||
"cabextract",
|
||||
)
|
||||
message_lc = message.lower()
|
||||
if any(pattern in message_lc for pattern in noisy_patterns):
|
||||
self.logger.debug("Automated prefix detail: %s", message)
|
||||
return
|
||||
|
||||
# Restart Steam
|
||||
print("\nRestarting Steam...")
|
||||
if shortcut_handler.secure_steam_restart():
|
||||
print(f"{COLOR_INFO}Steam restarted successfully.{COLOR_RESET}")
|
||||
|
||||
# Display manual Proton steps
|
||||
from .menu_handler import ModlistMenuHandler
|
||||
from .config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
menu_handler = ModlistMenuHandler(config_handler)
|
||||
menu_handler._display_manual_proton_steps(shortcut_name)
|
||||
|
||||
input(f"\n{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}")
|
||||
|
||||
# Get the updated AppID after launch
|
||||
new_app_id = shortcut_handler.get_appid_for_shortcut(shortcut_name, mo2_exe_path)
|
||||
if new_app_id and new_app_id.isdigit() and int(new_app_id) > 0:
|
||||
app_id = new_app_id
|
||||
else:
|
||||
print(f"{COLOR_ERROR}Could not find valid AppID after launch. Configuration may not work properly.{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}{message}{COLOR_RESET}")
|
||||
|
||||
try:
|
||||
_result = prefix_service.run_working_workflow(
|
||||
shortcut_name, install_dir_str, mo2_exe_path, _cli_progress, steamdeck=self.steamdeck
|
||||
)
|
||||
except Exception as _wf_err:
|
||||
from jackify.shared.errors import JackifyError
|
||||
if isinstance(_wf_err, JackifyError):
|
||||
self.logger.error(f"Automated prefix setup failed: {_wf_err.message}")
|
||||
print(f"{COLOR_ERROR}{_wf_err.message}{COLOR_RESET}")
|
||||
if _wf_err.suggestion:
|
||||
print(f"{COLOR_INFO}What to do: {_wf_err.suggestion}{COLOR_RESET}")
|
||||
else:
|
||||
print(f"{COLOR_ERROR}Steam restart failed. Please restart manually and configure later.{COLOR_RESET}")
|
||||
return
|
||||
|
||||
# Step 3: Build configuration context with the AppID
|
||||
self.logger.error(f"Automated prefix setup failed: {_wf_err}")
|
||||
print(f"{COLOR_ERROR}Automated prefix setup failed. Check logs for details.{COLOR_RESET}")
|
||||
return
|
||||
|
||||
if isinstance(_result, tuple) and len(_result) == 4:
|
||||
success, _prefix_path, app_id, _last_ts = _result
|
||||
else:
|
||||
success, app_id = False, None
|
||||
|
||||
if not success:
|
||||
self.logger.error("Automated prefix setup failed")
|
||||
print(f"{COLOR_ERROR}Automated prefix setup failed. Check logs for details.{COLOR_RESET}")
|
||||
return
|
||||
|
||||
config_context = {
|
||||
'name': shortcut_name,
|
||||
'appid': app_id,
|
||||
@@ -496,17 +513,14 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
'mo2_exe_path': mo2_exe_path,
|
||||
'resolution': self.context.get('resolution'),
|
||||
'skip_confirmation': is_gui_mode,
|
||||
'manual_steps_completed': not is_gui_mode # True if we did manual steps above
|
||||
'manual_steps_completed': True
|
||||
}
|
||||
|
||||
# Step 4: Use ModlistMenuHandler to run the complete configuration
|
||||
|
||||
from .menu_handler import ModlistMenuHandler
|
||||
from .config_handler import ConfigHandler
|
||||
|
||||
|
||||
config_handler = ConfigHandler()
|
||||
modlist_menu = ModlistMenuHandler(config_handler)
|
||||
|
||||
self.logger.info("Running post-installation configuration phase")
|
||||
configuration_success = modlist_menu.run_modlist_configuration_phase(config_context)
|
||||
|
||||
if configuration_success:
|
||||
@@ -524,4 +538,3 @@ class ModlistInstallCLIConfigurationMixin:
|
||||
else:
|
||||
print(f"{COLOR_WARNING}Could not detect game type from ModOrganizer.ini for automated configuration.{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}You may need to manually configure the modlist for Steam/Proton.{COLOR_RESET}")
|
||||
|
||||
|
||||
@@ -1,13 +1,59 @@
|
||||
"""TTW integration methods for ModlistInstallCLI (Mixin)."""
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_WARNING
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _strip_ansi_control_codes(text: str) -> str:
|
||||
"""Strip ANSI escape/control sequences from CLI output lines."""
|
||||
return re.sub(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])', '', text or '')
|
||||
|
||||
|
||||
def prompt_ttw_if_eligible(install_dir: str, modlist_name: str) -> None:
|
||||
"""Standalone TTW prompt usable outside the mixin context (e.g. CLI configure command).
|
||||
|
||||
Detects game type from ModOrganizer.ini, resolves the best available modlist name,
|
||||
checks whitelist eligibility, and runs the interactive TTW prompt if applicable.
|
||||
"""
|
||||
try:
|
||||
# Detect game type from ModOrganizer.ini
|
||||
mo2_ini = Path(install_dir) / "ModOrganizer.ini"
|
||||
game_type = "skyrim"
|
||||
if mo2_ini.exists():
|
||||
content = mo2_ini.read_text(encoding="utf-8", errors="ignore").lower()
|
||||
if "nvse_loader.exe" in content or "fallout new vegas" in content:
|
||||
game_type = "falloutnv"
|
||||
elif "fose_loader.exe" in content or "fallout 3" in content:
|
||||
game_type = "fallout3"
|
||||
|
||||
if game_type not in ("falloutnv", "fallout_new_vegas"):
|
||||
return
|
||||
|
||||
# Best available name: meta file, then selected_profile, then caller-supplied name
|
||||
from jackify.backend.utils.modlist_meta import get_modlist_name
|
||||
identified_name = get_modlist_name(install_dir) or modlist_name
|
||||
if not identified_name:
|
||||
return
|
||||
|
||||
class _Adapter(ModlistInstallCLITTWMixin):
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.verbose = False
|
||||
self.filesystem_handler = None
|
||||
self.config_handler = None
|
||||
|
||||
_Adapter()._check_and_prompt_ttw_integration(install_dir, game_type, identified_name)
|
||||
except Exception as e:
|
||||
logger.error("TTW post-configure check failed: %s", e, exc_info=True)
|
||||
|
||||
|
||||
class ModlistInstallCLITTWMixin:
|
||||
"""Mixin providing TTW integration methods."""
|
||||
|
||||
@@ -26,7 +72,38 @@ class ModlistInstallCLITTWMixin:
|
||||
print(f"TTW combines Fallout 3 and New Vegas into a single game.")
|
||||
print(f"\nWould you like to install TTW now?")
|
||||
|
||||
user_input = input(f"{COLOR_PROMPT}Install TTW? (yes/no): {COLOR_RESET}").strip().lower()
|
||||
# Some CLI entrypoint signal handlers currently call sys.exit(), which can interrupt
|
||||
# this prompt unexpectedly. Temporarily convert SIGINT/SIGTERM to KeyboardInterrupt
|
||||
# and keep prompting so users can answer explicitly.
|
||||
original_sigint = signal.getsignal(signal.SIGINT)
|
||||
original_sigterm = signal.getsignal(signal.SIGTERM)
|
||||
|
||||
def _prompt_signal_handler(signum, frame):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
try:
|
||||
signal.signal(signal.SIGINT, _prompt_signal_handler)
|
||||
signal.signal(signal.SIGTERM, _prompt_signal_handler)
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = input(f"{COLOR_PROMPT}Install TTW now? (Y/n): {COLOR_RESET}").strip().lower()
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{COLOR_WARNING}TTW prompt interrupted. Please type yes or no.{COLOR_RESET}")
|
||||
continue
|
||||
except EOFError:
|
||||
print(f"\n{COLOR_WARNING}No input available. Skipping TTW installation.{COLOR_RESET}")
|
||||
return
|
||||
|
||||
if user_input == "":
|
||||
user_input = "y"
|
||||
if user_input in ['yes', 'y', 'no', 'n']:
|
||||
break
|
||||
|
||||
print(f"{COLOR_WARNING}Please answer yes or no.{COLOR_RESET}")
|
||||
finally:
|
||||
signal.signal(signal.SIGINT, original_sigint)
|
||||
signal.signal(signal.SIGTERM, original_sigterm)
|
||||
|
||||
if user_input in ['yes', 'y']:
|
||||
self._launch_ttw_installation(modlist_name, install_dir)
|
||||
@@ -106,15 +183,26 @@ class ModlistInstallCLITTWMixin:
|
||||
|
||||
# Import TTW installation handler
|
||||
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
from pathlib import Path
|
||||
|
||||
system_info = SystemInfo()
|
||||
is_steamdeck = bool(getattr(self, 'steamdeck', False))
|
||||
if not is_steamdeck:
|
||||
try:
|
||||
is_steamdeck = PlatformDetectionService.get_instance().is_steamdeck
|
||||
except Exception:
|
||||
is_steamdeck = False
|
||||
|
||||
filesystem_handler = getattr(self, 'filesystem_handler', None) or FileSystemHandler()
|
||||
config_handler = getattr(self, 'config_handler', None) or ConfigHandler()
|
||||
|
||||
ttw_installer_handler = TTWInstallerHandler(
|
||||
steamdeck=system_info.is_steamdeck if hasattr(system_info, 'is_steamdeck') else False,
|
||||
steamdeck=is_steamdeck,
|
||||
verbose=self.verbose if hasattr(self, 'verbose') else False,
|
||||
filesystem_handler=self.filesystem_handler if hasattr(self, 'filesystem_handler') else None,
|
||||
config_handler=self.config_handler if hasattr(self, 'config_handler') else None
|
||||
filesystem_handler=filesystem_handler,
|
||||
config_handler=config_handler
|
||||
)
|
||||
|
||||
# Check if TTW_Linux_Installer is installed
|
||||
@@ -122,7 +210,9 @@ class ModlistInstallCLITTWMixin:
|
||||
|
||||
if not ttw_installer_handler.ttw_installer_installed:
|
||||
print(f"{COLOR_INFO}TTW_Linux_Installer is not installed.{COLOR_RESET}")
|
||||
user_input = input(f"{COLOR_PROMPT}Install TTW_Linux_Installer? (yes/no): {COLOR_RESET}").strip().lower()
|
||||
user_input = input(f"{COLOR_PROMPT}Install TTW_Linux_Installer? (Y/n): {COLOR_RESET}").strip().lower()
|
||||
if user_input == "":
|
||||
user_input = "y"
|
||||
|
||||
if user_input not in ['yes', 'y']:
|
||||
print(f"{COLOR_INFO}TTW installation cancelled.{COLOR_RESET}")
|
||||
@@ -152,7 +242,7 @@ class ModlistInstallCLITTWMixin:
|
||||
|
||||
# Prompt for TTW installation directory
|
||||
print(f"\n{COLOR_PROMPT}TTW Installation Directory{COLOR_RESET}")
|
||||
default_ttw_dir = os.path.join(install_dir, 'TTW')
|
||||
default_ttw_dir = os.path.join(install_dir, 'mods', '[NoDelete] Tale of Two Wastelands')
|
||||
print(f"Default: {default_ttw_dir}")
|
||||
ttw_install_dir = input(f"{COLOR_PROMPT}TTW install directory (Enter for default): {COLOR_RESET}").strip()
|
||||
|
||||
@@ -162,14 +252,105 @@ class ModlistInstallCLITTWMixin:
|
||||
# Run TTW installation
|
||||
print(f"\n{COLOR_INFO}Installing TTW using TTW_Linux_Installer...{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}This may take a while (15-30 minutes depending on your system).{COLOR_RESET}")
|
||||
phase_state = {"current": "Processing", "last_rendered": ""}
|
||||
progress_line_active = {"value": False}
|
||||
|
||||
success, message = ttw_installer_handler.install_ttw_backend(Path(mpi_path), Path(ttw_install_dir))
|
||||
def _ttw_output_callback(line: str):
|
||||
clean = _strip_ansi_control_codes(line or "").strip()
|
||||
if not clean:
|
||||
return
|
||||
|
||||
lower = clean.lower()
|
||||
rendered = ""
|
||||
|
||||
# Match GUI behavior: explicit Loading manifest counter line
|
||||
manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower)
|
||||
if manifest_match:
|
||||
current = int(manifest_match.group(1))
|
||||
total = int(manifest_match.group(2))
|
||||
phase_state["current"] = "Loading manifest"
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
rendered = f"[TTW] {phase_state['current']}: {current:,}/{total:,} ({percent}%)"
|
||||
else:
|
||||
# Match GUI behavior: generic [X/Y] counters with current phase name.
|
||||
progress_match = re.search(r'\[(\d+)/(\d+)\]', clean)
|
||||
if progress_match:
|
||||
current = int(progress_match.group(1))
|
||||
total = int(progress_match.group(2))
|
||||
percent = int((current / total) * 100) if total > 0 else 0
|
||||
rendered = f"[TTW] {phase_state['current']}: {current:,}/{total:,} ({percent}%)"
|
||||
else:
|
||||
# Update phase state from milestone-like lines, then echo milestones.
|
||||
if 'manifest' in lower:
|
||||
phase_state["current"] = "Loading manifest"
|
||||
elif any(token in lower for token in ('extract', 'decompress', 'installing', 'copying', 'merge')):
|
||||
phase_state["current"] = clean
|
||||
|
||||
is_milestone = any(token in lower for token in ('===', 'complete', 'finished', 'starting', 'valid'))
|
||||
is_error = 'error:' in lower
|
||||
is_warning = 'warning:' in lower
|
||||
if is_milestone or is_error or is_warning:
|
||||
rendered = f"[TTW] {clean}"
|
||||
|
||||
if not rendered or rendered == phase_state["last_rendered"]:
|
||||
return
|
||||
phase_state["last_rendered"] = rendered
|
||||
if rendered.startswith("[TTW] Loading manifest:") or re.search(r'^\[TTW\] .+?: [\d,]+/[\d,]+ \(\d+%\)$', rendered):
|
||||
# In-place progress updates for counters/phases.
|
||||
print(f"\r{COLOR_INFO}{rendered}{COLOR_RESET}", end="", flush=True)
|
||||
progress_line_active["value"] = True
|
||||
else:
|
||||
# Non-progress milestones/errors get normal line output.
|
||||
if progress_line_active["value"]:
|
||||
print()
|
||||
progress_line_active["value"] = False
|
||||
print(f"{COLOR_INFO}{rendered}{COLOR_RESET}")
|
||||
|
||||
success, message = ttw_installer_handler.install_ttw_backend_with_output_stream(
|
||||
Path(mpi_path),
|
||||
Path(ttw_install_dir),
|
||||
output_callback=_ttw_output_callback,
|
||||
)
|
||||
if progress_line_active["value"]:
|
||||
print()
|
||||
|
||||
if success:
|
||||
ttw_output_path = Path(ttw_install_dir)
|
||||
ttw_version = ""
|
||||
version_match = re.search(r'v?(\d+\.\d+(?:\.\d+)?)', Path(mpi_path).stem, re.IGNORECASE)
|
||||
if version_match:
|
||||
ttw_version = version_match.group(1)
|
||||
|
||||
skip_copy = False
|
||||
mods_dir = Path(install_dir) / "mods"
|
||||
if ttw_output_path.parent == mods_dir:
|
||||
versioned_name = f"[NoDelete] Tale of Two Wastelands {ttw_version}".strip() if ttw_version else "[NoDelete] Tale of Two Wastelands"
|
||||
versioned_path = mods_dir / versioned_name
|
||||
if ttw_output_path != versioned_path and ttw_output_path.exists():
|
||||
if versioned_path.exists():
|
||||
shutil.rmtree(versioned_path)
|
||||
ttw_output_path.rename(versioned_path)
|
||||
ttw_output_path = versioned_path
|
||||
skip_copy = True
|
||||
|
||||
print(f"\n{COLOR_INFO}Integrating TTW into modlist load order...{COLOR_RESET}")
|
||||
integration_success = TTWInstallerHandler.integrate_ttw_into_modlist(
|
||||
ttw_output_path=ttw_output_path,
|
||||
modlist_install_dir=Path(install_dir),
|
||||
ttw_version=ttw_version,
|
||||
skip_copy=skip_copy,
|
||||
)
|
||||
|
||||
if not integration_success:
|
||||
print(f"{COLOR_ERROR}TTW installed, but integration into modlist failed.{COLOR_RESET}")
|
||||
print(f"{COLOR_ERROR}Please check TTW_Install_workflow.log for details.{COLOR_RESET}")
|
||||
return
|
||||
|
||||
print(f"\n{COLOR_INFO}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
|
||||
print(f"{COLOR_INFO}TTW Installation Complete!{COLOR_RESET}")
|
||||
print(f"{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
|
||||
print(f"\nTTW has been installed to: {ttw_install_dir}")
|
||||
print(f"\nTTW has been installed to: {ttw_output_path}")
|
||||
print(f"TTW has been integrated into '{modlist_name}' (modlist.txt + plugins.txt updated).")
|
||||
print(f"The modlist '{modlist_name}' is now ready to use with TTW.")
|
||||
else:
|
||||
print(f"\n{COLOR_ERROR}TTW installation failed. Check the logs for details.{COLOR_RESET}")
|
||||
@@ -177,4 +358,4 @@ class ModlistInstallCLITTWMixin:
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during TTW installation: {e}", exc_info=True)
|
||||
print(f"{COLOR_ERROR}Error during TTW installation: {e}{COLOR_RESET}")
|
||||
print(f"{COLOR_ERROR}Error during TTW installation: {e}{COLOR_RESET}")
|
||||
|
||||
@@ -207,8 +207,11 @@ class ModlistWineOpsMixin:
|
||||
# Add game-specific extras
|
||||
if "skyrim" in game or "fallout4" in game or "starfield" in game or "oblivion_remastered" in game or "enderal" in game:
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7"]
|
||||
elif "falloutnewvegas" in game or "fnv" in game or "oblivion" in game:
|
||||
elif "falloutnewvegas" in game or "fnv" in game or "fallout3" in game or "fo3" in game or "oblivion" in game:
|
||||
extras += ["d3dx9_43", "d3dx9"]
|
||||
else:
|
||||
# Unknown game type — install the union of all known component sets
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "d3dx9_43", "d3dx9"]
|
||||
# Add modlist-specific extras
|
||||
modlist_lower = modlist_name.lower().replace(" ", "") if modlist_name else ""
|
||||
for key, components in self.MODLIST_WINE_COMPONENTS.items():
|
||||
|
||||
@@ -66,12 +66,12 @@ class OAuthTokenHandler:
|
||||
# Linux machine-id
|
||||
with open('/etc/machine-id', 'r') as f:
|
||||
machine_id = f.read().strip()
|
||||
except:
|
||||
except (OSError, IOError):
|
||||
try:
|
||||
# Alternative locations
|
||||
with open('/var/lib/dbus/machine-id', 'r') as f:
|
||||
machine_id = f.read().strip()
|
||||
except:
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
|
||||
# Combine multiple sources of machine-specific data
|
||||
@@ -221,7 +221,7 @@ class OAuthTokenHandler:
|
||||
# Clean up temp file on error
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except:
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
raise e
|
||||
|
||||
|
||||
@@ -87,10 +87,12 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
# Alternative format: "[timestamp] StatusText (current/total) - speed"
|
||||
# Alternative format: "[timestamp] StatusText (current/total) - speed [- Xunit remaining]"
|
||||
# Example: "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s"
|
||||
# Example (engine 0.4.8+): "[00:00:10] Downloading Mod Archives (17/214) - 6.8MB/s - 23.1GB remaining"
|
||||
# Timestamp prefix is now optional — engine no longer emits [HH:MM:SS].
|
||||
self.timestamp_status_pattern = re.compile(
|
||||
r'\[[^\]]+\]\s+(.+?)\s+\((\d+)/(\d+)\)\s*-\s*([^\s]+)',
|
||||
r'(?:\[[^\]]+\]\s+)?(.+?)\s+\((\d+)/(\d+)\)\s*-\s*([^\s]+)(?:\s*-\s*([\d.]+)\s*(B|KB|MB|GB|TB)\s+remaining)?',
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
@@ -230,18 +232,33 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
|
||||
if speed_info:
|
||||
operation = self._detect_operation_from_line(status_text)
|
||||
result.speed_info = (operation.value, speed_info)
|
||||
|
||||
|
||||
# Extract remaining size if present (engine 0.4.8+: "- 23.1GB remaining")
|
||||
remaining_val = timestamp_match.group(5)
|
||||
remaining_unit = timestamp_match.group(6)
|
||||
if remaining_val and remaining_unit:
|
||||
remaining_bytes = self._convert_to_bytes(float(remaining_val), remaining_unit)
|
||||
if remaining_bytes > 0 and max_steps > 0 and current_step < max_steps:
|
||||
fraction_done = current_step / max_steps
|
||||
# Estimate total from remaining and fraction; clamp denominator to avoid div/0 near completion
|
||||
estimated_total = remaining_bytes / max(1.0 - fraction_done, 0.01)
|
||||
data_processed = int(estimated_total - remaining_bytes)
|
||||
result.data_info = (max(0, data_processed), int(estimated_total))
|
||||
elif remaining_bytes > 0:
|
||||
result.data_info = (0, int(remaining_bytes))
|
||||
|
||||
# Calculate overall percentage from step progress
|
||||
if max_steps > 0:
|
||||
result.overall_percent = (current_step / max_steps) * 100.0
|
||||
|
||||
|
||||
result.has_progress = True
|
||||
|
||||
# Try .wabbajack download format: "[timestamp] Downloading .wabbajack (size/size) - speed"
|
||||
# Example: "[00:02:08] Downloading .wabbajack (739.2/1947.2MB) - 6.0MB/s"
|
||||
# Also handles: "[00:02:08] Downloading modlist.wabbajack (739.2/1947.2MB) - 6.0MB/s"
|
||||
# Timestamp prefix is optional in newer engine output.
|
||||
wabbajack_download_pattern = re.compile(
|
||||
r'\[[^\]]+\]\s+Downloading\s+([^\s]+\.wabbajack|\.wabbajack)\s+\(([^)]+)\)\s*-\s*([^\s]+)',
|
||||
r'(?:\[[^\]]+\]\s+)?Downloading\s+([^\s]+\.wabbajack|\.wabbajack)\s+\(([^)]+)\)\s*-\s*([^\s]+)',
|
||||
re.IGNORECASE
|
||||
)
|
||||
wabbajack_match = wabbajack_download_pattern.search(line)
|
||||
@@ -294,13 +311,15 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
|
||||
|
||||
# Set phase
|
||||
result.phase = InstallationPhase.DOWNLOAD
|
||||
result.phase_name = f"Downloading {filename}"
|
||||
phase_target = filename
|
||||
if phase_target.lower().startswith("downloading "):
|
||||
phase_target = phase_target[len("downloading "):].strip()
|
||||
result.phase_name = f"Downloading {phase_target}"
|
||||
|
||||
# Create FileProgress entry for .wabbajack file
|
||||
if data_info:
|
||||
current_bytes, total_bytes = data_info
|
||||
percent = (current_bytes / total_bytes) * 100.0 if total_bytes > 0 else 0.0
|
||||
from jackify.shared.progress_models import FileProgress, OperationType
|
||||
file_progress = FileProgress(
|
||||
filename=filename,
|
||||
operation=OperationType.DOWNLOAD,
|
||||
@@ -313,6 +332,50 @@ class ProgressParser(ProgressParserPhaseMixin, ProgressParserFilesMixin, Progres
|
||||
|
||||
result.has_progress = True
|
||||
|
||||
# Try to extract install progress format:
|
||||
# "Installing files X/Y (GB/GB) - Converting textures: N/M"
|
||||
install_match = re.match(
|
||||
r'Installing files\s+(\d+)/(\d+)\s+\(([^)]+)\)(?:\s*-\s*Converting textures:\s*(\d+)/(\d+))?',
|
||||
line.strip(), re.IGNORECASE)
|
||||
if install_match:
|
||||
result.phase = InstallationPhase.INSTALL
|
||||
result.step_info = (int(install_match.group(1)), int(install_match.group(2)))
|
||||
data_info = self._parse_data_string(install_match.group(3))
|
||||
if data_info:
|
||||
result.data_info = data_info
|
||||
current_bytes, total_bytes = data_info
|
||||
if total_bytes > 0:
|
||||
result.overall_percent = (current_bytes / total_bytes) * 100.0
|
||||
if install_match.group(4) and install_match.group(5):
|
||||
fp = FileProgress(
|
||||
filename='_tex',
|
||||
operation=OperationType.INSTALL,
|
||||
percent=0.0,
|
||||
speed=-1.0
|
||||
)
|
||||
fp._texture_counter = (int(install_match.group(4)), int(install_match.group(5)))
|
||||
fp._hidden = True
|
||||
result.file_progress = fp
|
||||
result.has_progress = True
|
||||
|
||||
# Conversion-only status line (without "Installing files ...")
|
||||
conversion_match = re.search(r'Converting textures:\s*(\d+)/(\d+)', line, re.IGNORECASE)
|
||||
if conversion_match and not install_match:
|
||||
if not result.phase:
|
||||
result.phase = InstallationPhase.INSTALL
|
||||
if not result.phase_name:
|
||||
result.phase_name = "Converting textures"
|
||||
fp = FileProgress(
|
||||
filename='_tex',
|
||||
operation=OperationType.INSTALL,
|
||||
percent=0.0,
|
||||
speed=-1.0
|
||||
)
|
||||
fp._texture_counter = (int(conversion_match.group(1)), int(conversion_match.group(2)))
|
||||
fp._hidden = True
|
||||
result.file_progress = fp
|
||||
result.has_progress = True
|
||||
|
||||
# Try to extract step information (fallback)
|
||||
if not result.step_info:
|
||||
step_info = self._extract_step_info(line)
|
||||
|
||||
@@ -24,6 +24,12 @@ class ProgressParserExtractionMixin:
|
||||
|
||||
def _extract_step_info(self, line: str) -> Optional[Tuple[int, int]]:
|
||||
"""Extract step information like [12/14]."""
|
||||
line_lower = line.lower()
|
||||
# Texture conversion counters are tracked separately; don't let generic
|
||||
# step parsing overwrite the primary install counter.
|
||||
if 'converting textures' in line_lower and 'installing files' not in line_lower:
|
||||
return None
|
||||
|
||||
match = self.wabbajack_status_pattern.search(line)
|
||||
if match:
|
||||
current = int(match.group(1))
|
||||
|
||||
@@ -20,8 +20,13 @@ class ProgressParserPhaseMixin:
|
||||
phase = self._map_section_to_phase(section_name)
|
||||
return (phase, section_match.group(1).strip())
|
||||
|
||||
# [FILE_PROGRESS] lines drive file activity only — skip phase extraction for them
|
||||
if '[FILE_PROGRESS]' in line:
|
||||
return None
|
||||
|
||||
# Make the [timestamp] prefix optional — engine no longer emits it.
|
||||
action_match = re.search(
|
||||
r'\[.*?\]\s*(Installing|Downloading|Extracting|Validating|Processing|Checking existing)',
|
||||
r'(?:\[.*?\]\s*)?(Installing|Downloading|Extracting|Validating|Processing|Checking existing)',
|
||||
line,
|
||||
re.IGNORECASE
|
||||
)
|
||||
@@ -51,13 +56,18 @@ class ProgressParserPhaseMixin:
|
||||
return InstallationPhase.DOWNLOAD
|
||||
elif 'extract' in section_lower:
|
||||
return InstallationPhase.EXTRACT
|
||||
elif 'validate' in section_lower or 'verif' in section_lower:
|
||||
elif 'hash' in section_lower or 'validate' in section_lower or 'verif' in section_lower:
|
||||
return InstallationPhase.VALIDATE
|
||||
elif 'install' in section_lower:
|
||||
return InstallationPhase.INSTALL
|
||||
elif 'bsa' in section_lower or 'building' in section_lower:
|
||||
return InstallationPhase.INSTALL
|
||||
elif 'finaliz' in section_lower or 'complet' in section_lower:
|
||||
return InstallationPhase.FINALIZE
|
||||
elif 'configur' in section_lower or 'initializ' in section_lower:
|
||||
elif ('configur' in section_lower or 'initializ' in section_lower
|
||||
or 'looking' in section_lower or 'cleaning' in section_lower
|
||||
or 'unmodified' in section_lower or 'updating' in section_lower
|
||||
or 'folder' in section_lower or 'delete' in section_lower):
|
||||
return InstallationPhase.INITIALIZATION
|
||||
else:
|
||||
return InstallationPhase.UNKNOWN
|
||||
|
||||
@@ -94,9 +94,6 @@ class ProgressStateProcessingMixin:
|
||||
updated = True
|
||||
|
||||
if parsed.file_progress:
|
||||
if hasattr(parsed.file_progress, '_hidden') and parsed.file_progress._hidden:
|
||||
return updated
|
||||
|
||||
if hasattr(parsed.file_progress, '_texture_counter'):
|
||||
tex_current, tex_total = parsed.file_progress._texture_counter
|
||||
self.state.texture_conversion_current = tex_current
|
||||
@@ -109,6 +106,9 @@ class ProgressStateProcessingMixin:
|
||||
self.state.bsa_building_total = bsa_total
|
||||
updated = True
|
||||
|
||||
if hasattr(parsed.file_progress, '_hidden') and parsed.file_progress._hidden:
|
||||
return updated
|
||||
|
||||
if parsed.file_progress.filename.lower().endswith('.wabbajack'):
|
||||
self._wabbajack_entry_name = parsed.file_progress.filename
|
||||
self._remove_synthetic_wabbajack()
|
||||
|
||||
@@ -153,7 +153,7 @@ class ShortcutLaunchOptionsMixin:
|
||||
content = (
|
||||
"[handlers]\n"
|
||||
"size=1\n"
|
||||
"1\\games=\"skyrimse,skyrim\"\n"
|
||||
"1\\games=\"skyrimse,skyrim,fallout4,falloutnv,fallout3,oblivion,enderal,starfield\"\n"
|
||||
f"1\\executable={win_path}\n"
|
||||
"1\\arguments=\n"
|
||||
)
|
||||
|
||||
@@ -145,7 +145,7 @@ def increase_file_descriptor_limit(target_limit=1048576):
|
||||
# Get current limit for reporting
|
||||
try:
|
||||
soft_limit, _ = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||
except:
|
||||
except (OSError, ValueError):
|
||||
soft_limit = "unknown"
|
||||
|
||||
return False, soft_limit, soft_limit, f"Failed to increase file descriptor limit: {e}"
|
||||
@@ -154,7 +154,7 @@ class ProcessManager:
|
||||
"""
|
||||
Shared process manager for robust subprocess launching, tracking, and cancellation.
|
||||
"""
|
||||
def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0):
|
||||
def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False):
|
||||
self.cmd = cmd
|
||||
# Default to cleaned environment if None to prevent AppImage variable inheritance
|
||||
if env is None:
|
||||
@@ -164,15 +164,17 @@ class ProcessManager:
|
||||
self.cwd = cwd
|
||||
self.text = text
|
||||
self.bufsize = bufsize
|
||||
self.separate_stderr = separate_stderr
|
||||
self.proc = None
|
||||
self.process_group_pid = None
|
||||
self._start_process()
|
||||
|
||||
def _start_process(self):
|
||||
stderr_arg = subprocess.PIPE if self.separate_stderr else subprocess.STDOUT
|
||||
self.proc = subprocess.Popen(
|
||||
self.cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
stderr=stderr_arg,
|
||||
env=self.env,
|
||||
cwd=self.cwd,
|
||||
text=self.text,
|
||||
@@ -186,38 +188,48 @@ class ProcessManager:
|
||||
Attempt to robustly terminate the process and its children.
|
||||
"""
|
||||
cleanup_attempts = 0
|
||||
if self.proc:
|
||||
try:
|
||||
self.proc.terminate()
|
||||
try:
|
||||
if self.proc:
|
||||
try:
|
||||
self.proc.wait(timeout=timeout_terminate)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.proc.kill()
|
||||
try:
|
||||
self.proc.wait(timeout=timeout_kill)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
# Kill process group if possible
|
||||
if self.process_group_pid:
|
||||
try:
|
||||
os.killpg(self.process_group_pid, signal.SIGKILL)
|
||||
self.proc.terminate()
|
||||
try:
|
||||
self.proc.wait(timeout=timeout_terminate)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
# Last resort: pkill by command name
|
||||
while cleanup_attempts < max_cleanup_attempts:
|
||||
try:
|
||||
subprocess.run(['pkill', '-f', os.path.basename(self.cmd[0])], timeout=5, capture_output=True)
|
||||
self.proc.kill()
|
||||
try:
|
||||
self.proc.wait(timeout=timeout_kill)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
cleanup_attempts += 1
|
||||
# Kill entire process group (catches 7zz and other child processes)
|
||||
if self.process_group_pid:
|
||||
try:
|
||||
os.killpg(self.process_group_pid, signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
# Last resort: pkill by command name
|
||||
while cleanup_attempts < max_cleanup_attempts:
|
||||
try:
|
||||
subprocess.run(['pkill', '-f', os.path.basename(self.cmd[0])], timeout=5, capture_output=True)
|
||||
except Exception:
|
||||
pass
|
||||
cleanup_attempts += 1
|
||||
finally:
|
||||
# Always close pipes — unblocks threads blocked on read(1) or iterating stderr
|
||||
if self.proc:
|
||||
for pipe in (self.proc.stdout, self.proc.stderr):
|
||||
if pipe:
|
||||
try:
|
||||
pipe.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def is_running(self):
|
||||
return self.proc and self.proc.poll() is None
|
||||
@@ -234,5 +246,8 @@ class ProcessManager:
|
||||
|
||||
def read_stdout_char(self):
|
||||
if self.proc and self.proc.stdout:
|
||||
return self.proc.stdout.read(1)
|
||||
return None
|
||||
try:
|
||||
return self.proc.stdout.read(1)
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
return None
|
||||
@@ -229,7 +229,7 @@ class TTWInstallerBackendMixin:
|
||||
return False, f"Error executing TTW_Linux_Installer: {e}"
|
||||
|
||||
@staticmethod
|
||||
def integrate_ttw_into_modlist(ttw_output_path: Path, modlist_install_dir: Path, ttw_version: str) -> bool:
|
||||
def integrate_ttw_into_modlist(ttw_output_path: Path, modlist_install_dir: Path, ttw_version: str, skip_copy: bool = False) -> bool:
|
||||
"""Integrate TTW output into a modlist's MO2 structure."""
|
||||
import shutil
|
||||
logging_handler = LoggingHandler()
|
||||
@@ -246,12 +246,16 @@ class TTWInstallerBackendMixin:
|
||||
return False
|
||||
mod_folder_name = f"[NoDelete] Tale of Two Wastelands {ttw_version}" if ttw_version else "[NoDelete] Tale of Two Wastelands"
|
||||
target_mod_dir = mods_dir / mod_folder_name
|
||||
logger.info("Copying TTW output to %s", target_mod_dir)
|
||||
if target_mod_dir.exists():
|
||||
logger.info("Removing existing TTW mod at %s", target_mod_dir)
|
||||
shutil.rmtree(target_mod_dir)
|
||||
shutil.copytree(ttw_output_path, target_mod_dir)
|
||||
logger.info("TTW output copied successfully")
|
||||
if skip_copy:
|
||||
# TTW was installed directly to target_mod_dir — no copy needed
|
||||
logger.info("TTW already at target location, skipping copy: %s", target_mod_dir)
|
||||
else:
|
||||
logger.info("Copying TTW output to %s", target_mod_dir)
|
||||
if target_mod_dir.exists():
|
||||
logger.info("Removing existing TTW mod at %s", target_mod_dir)
|
||||
shutil.rmtree(target_mod_dir)
|
||||
shutil.copytree(ttw_output_path, target_mod_dir)
|
||||
logger.info("TTW output copied successfully")
|
||||
ttw_esms = [
|
||||
"Fallout3.esm", "Anchorage.esm", "ThePitt.esm", "BrokenSteel.esm",
|
||||
"PointLookout.esm", "Zeta.esm", "TaleOfTwoWastelands.esm", "YUPTTW.esm"
|
||||
|
||||
@@ -36,7 +36,7 @@ class WabbajackParser:
|
||||
|
||||
# List of supported games in Jackify
|
||||
self.supported_games = [
|
||||
'skyrim', 'fallout4', 'falloutnv', 'oblivion',
|
||||
'skyrim', 'fallout4', 'falloutnv', 'fallout3', 'oblivion',
|
||||
'starfield', 'oblivion_remastered', 'enderal'
|
||||
]
|
||||
|
||||
|
||||
@@ -218,7 +218,9 @@ class WineUtilsProtonMixin:
|
||||
Path.home() / ".steam/root/compatibilitytools.d",
|
||||
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d",
|
||||
Path.home() / ".var/app/com.valvesoftware.Steam.CompatibilityTool.Proton-GE/.local/share/Steam/compatibilitytools.d",
|
||||
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d/GE-Proton"
|
||||
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d/GE-Proton",
|
||||
Path("/usr/share/steam/compatibilitytools.d"),
|
||||
Path("/usr/lib/steam/compatibilitytools.d"),
|
||||
]
|
||||
return [path for path in compat_paths if path.exists()]
|
||||
|
||||
@@ -357,7 +359,7 @@ class WineUtilsProtonMixin:
|
||||
if version_match:
|
||||
major_ver = int(version_match.group(1))
|
||||
minor_ver = int(version_match.group(2))
|
||||
priority = 200 + (major_ver * 10) + minor_ver
|
||||
priority = 200 + (major_ver * 10) + minor_ver # kept for backward compat; sort uses tuple key
|
||||
compat_name = WineUtilsProtonMixin._parse_compat_tool_name(proton_dir) or dir_name
|
||||
found_versions.append({
|
||||
'name': dir_name,
|
||||
@@ -374,7 +376,7 @@ class WineUtilsProtonMixin:
|
||||
logger.debug(f"Skipping {dir_name} - unknown GE-Proton version format")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error scanning {compat_path}: {e}")
|
||||
found_versions.sort(key=lambda x: x['priority'], reverse=True)
|
||||
found_versions.sort(key=lambda x: (x['major_version'], x['minor_version']), reverse=True)
|
||||
logger.info(f"Found {len(found_versions)} GE-Proton version(s)")
|
||||
return found_versions
|
||||
|
||||
@@ -427,7 +429,16 @@ class WineUtilsProtonMixin:
|
||||
all_versions.extend(WineUtilsProtonMixin.scan_ge_proton_versions())
|
||||
all_versions.extend(WineUtilsProtonMixin.scan_thirdparty_proton_versions())
|
||||
all_versions.extend(WineUtilsProtonMixin.scan_valve_proton_versions())
|
||||
all_versions.sort(key=lambda x: x['priority'], reverse=True)
|
||||
_TYPE_RANK = {'GE-Proton': 2, 'ThirdParty-Proton': 1, 'Valve-Proton': 0}
|
||||
all_versions.sort(
|
||||
key=lambda x: (
|
||||
_TYPE_RANK.get(x.get('type', ''), 0),
|
||||
x.get('major_version', 0),
|
||||
x.get('minor_version', 0),
|
||||
x.get('priority', 0),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
unique_versions = []
|
||||
seen_names = set()
|
||||
for version in all_versions:
|
||||
@@ -443,17 +454,16 @@ class WineUtilsProtonMixin:
|
||||
|
||||
@staticmethod
|
||||
def select_best_proton() -> Optional[Dict[str, Any]]:
|
||||
"""Select the best available Proton (GE or Valve). Excludes third-party builds."""
|
||||
"""Select the best available Proton version. Prefers GE-Proton, then Valve, then any third-party build."""
|
||||
available_versions = WineUtilsProtonMixin.scan_all_proton_versions()
|
||||
if not available_versions:
|
||||
logger.warning("No compatible Proton versions found")
|
||||
logger.warning("No Proton versions found")
|
||||
return None
|
||||
compatible_versions = [v for v in available_versions if v.get('type') in ('GE-Proton', 'Valve-Proton')]
|
||||
if not compatible_versions:
|
||||
logger.warning("No compatible Proton versions found (only third-party builds available)")
|
||||
return None
|
||||
best_version = compatible_versions[0]
|
||||
logger.info(f"Selected best Proton version: {best_version['name']} ({best_version['type']})")
|
||||
best_version = available_versions[0]
|
||||
if best_version.get('type') == 'ThirdParty-Proton':
|
||||
logger.debug(f"No GE/Valve Proton found; using third-party build: {best_version['name']}")
|
||||
else:
|
||||
logger.info(f"Selected Proton: {best_version['name']} ({best_version['type']})")
|
||||
return best_version
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -83,7 +83,7 @@ class WinetricksEnvMixin:
|
||||
self.logger.warning(f"User-selected Proton no longer exists: {user_proton_path}")
|
||||
|
||||
if not wine_binary:
|
||||
if user_proton_path == 'auto':
|
||||
if not user_proton_path or user_proton_path == 'auto':
|
||||
self.logger.info("Auto-detecting Proton (user selected 'auto')")
|
||||
best_proton = WineUtils.select_best_proton()
|
||||
if best_proton:
|
||||
|
||||
Reference in New Issue
Block a user