Sync from development - prepare for v0.1.7

This commit is contained in:
Omni
2025-11-04 12:54:15 +00:00
parent 91ac08afb2
commit 9680814bbb
25 changed files with 4560 additions and 259 deletions

View File

@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
Wabbajack modlists natively on Linux systems.
"""
__version__ = "0.1.6.6"
__version__ = "0.1.7"

View File

@@ -0,0 +1,3 @@
"""
Data package for static configuration and reference data.
"""

View File

@@ -0,0 +1,46 @@
"""
TTW-Compatible Modlists Configuration
Defines which Fallout New Vegas modlists support Tale of Two Wastelands.
This whitelist determines when Jackify should offer TTW installation after
a successful modlist installation.
"""
TTW_COMPATIBLE_MODLISTS = {
# Exact modlist names that support/require TTW
"exact_matches": [
"Begin Again",
"Uranium Fever",
"The Badlands",
"Wild Card TTW",
],
# Pattern matching for modlist names (regex)
"patterns": [
r".*TTW.*", # Any modlist with TTW in name
r".*Tale.*Two.*Wastelands.*",
]
}
def is_ttw_compatible(modlist_name: str) -> bool:
"""Check if modlist name matches TTW compatibility criteria
Args:
modlist_name: Name of the modlist to check
Returns:
bool: True if modlist is TTW-compatible, False otherwise
"""
import re
# Check exact matches
if modlist_name in TTW_COMPATIBLE_MODLISTS['exact_matches']:
return True
# Check pattern matches
for pattern in TTW_COMPATIBLE_MODLISTS['patterns']:
if re.match(pattern, modlist_name, re.IGNORECASE):
return True
return False

View File

@@ -20,10 +20,23 @@ logger = logging.getLogger(__name__)
class ConfigHandler:
"""
Handles application configuration and settings
Singleton pattern ensures all code shares the same instance
"""
_instance = None
_initialized = False
def __new__(cls):
if cls._instance is None:
cls._instance = super(ConfigHandler, cls).__new__(cls)
return cls._instance
def __init__(self):
"""Initialize configuration handler with default settings"""
# Only initialize once (singleton pattern)
if ConfigHandler._initialized:
return
ConfigHandler._initialized = True
self.config_dir = os.path.expanduser("~/.config/jackify")
self.config_file = os.path.join(self.config_dir, "config.json")
self.settings = {
@@ -45,13 +58,14 @@ class ConfigHandler:
# Load configuration if exists
self._load_config()
# If steam_path is not set, detect it
if not self.settings["steam_path"]:
self.settings["steam_path"] = self._detect_steam_path()
# Auto-detect and set Proton version on first run
if not self.settings.get("proton_path"):
# Auto-detect and set Proton version ONLY on first run (config file doesn't exist)
# Do NOT overwrite user's saved settings!
if not os.path.exists(self.config_file) and not self.settings.get("proton_path"):
self._auto_detect_proton()
# If jackify_data_dir is not set, initialize it to default
@@ -113,6 +127,10 @@ class ConfigHandler:
self._create_config_dir()
except Exception as e:
logger.error(f"Error loading configuration: {e}")
def reload_config(self):
"""Reload configuration from disk to pick up external changes"""
self._load_config()
def _create_config_dir(self):
"""Create configuration directory if it doesn't exist"""

View File

@@ -15,7 +15,7 @@ from .filesystem_handler import FileSystemHandler
from .config_handler import ConfigHandler
# Import color constants needed for print statements in this module
from .ui_colors import COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING, COLOR_RESET, COLOR_INFO, COLOR_PROMPT, COLOR_SELECTION
# Standard logging (no file handler) - LoggingHandler import removed
from .logging_handler import LoggingHandler
from .status_utils import show_status, clear_status
from .subprocess_utils import get_clean_subprocess_env
@@ -55,8 +55,10 @@ class HoolamikeHandler:
self.filesystem_handler = filesystem_handler
self.config_handler = config_handler
self.menu_handler = menu_handler
# Use standard logging (no file handler)
self.logger = logging.getLogger(__name__)
# Set up dedicated log file for TTW operations
logging_handler = LoggingHandler()
logging_handler.rotate_log_for_logger('ttw-install', 'TTW_Install_workflow.log')
self.logger = logging_handler.setup_logger('ttw-install', 'TTW_Install_workflow.log')
# --- Discovered/Managed State ---
self.game_install_paths: Dict[str, Path] = {}
@@ -213,7 +215,7 @@ class HoolamikeHandler:
if not self.hoolamike_config.get("games"):
f.write("# No games were detected by Jackify. Add game paths manually if needed.\n")
# Dump the actual YAML
yaml.dump(self.hoolamike_config, f, default_flow_style=False, sort_keys=False)
yaml.dump(self.hoolamike_config, f, default_flow_style=False, sort_keys=False, width=float('inf'))
self.logger.info("Configuration saved successfully.")
return True
except Exception as e:
@@ -224,9 +226,12 @@ class HoolamikeHandler:
"""Execute all discovery steps."""
self.logger.info("Starting Hoolamike feature discovery phase...")
# Check if Hoolamike is installed
self._check_hoolamike_installation()
# Detect game paths and update internal state + config
self._detect_and_update_game_paths()
self.logger.info("Hoolamike discovery phase complete.")
def _detect_and_update_game_paths(self):
@@ -242,22 +247,143 @@ class HoolamikeHandler:
self.logger.debug("Updating loaded hoolamike.yaml with detected game paths.")
if "games" not in self.hoolamike_config or not isinstance(self.hoolamike_config.get("games"), dict):
self.hoolamike_config["games"] = {} # Ensure games section exists
# Define a unified format for game names in config - no spaces
# Clear existing entries first to avoid duplicates
self.hoolamike_config["games"] = {}
# Add detected paths with proper formatting - no spaces
for game_name, detected_path in detected_paths.items():
formatted_name = self._format_game_name(game_name)
self.hoolamike_config["games"][formatted_name] = {"root_directory": str(detected_path)}
self.logger.info(f"Updated config with {len(detected_paths)} game paths using correct naming format (no spaces)")
# Save the updated config to disk so Hoolamike can read it
if detected_paths:
self.logger.info("Saving updated game paths to hoolamike.yaml")
self.save_hoolamike_config()
else:
self.logger.warning("Cannot update game paths in config because config is not loaded.")
# --- Methods for Hoolamike Tasks (To be implemented later) ---
# TODO: Update these methods to accept necessary parameters and update/save config
# --- Methods for Hoolamike Tasks ---
# GUI-safe, non-interactive installer used by Install TTW screen
def install_hoolamike(self, install_dir: Optional[Path] = None) -> tuple[bool, str]:
"""Non-interactive install/update of Hoolamike for GUI usage.
Downloads the latest Linux x86_64 release from GitHub, extracts it to the
Jackify-managed directory (~/Jackify/Hoolamike by default or provided install_dir),
sets executable permissions, and saves the install path to Jackify config.
Returns:
(success, message)
"""
try:
self._ensure_hoolamike_dirs_exist()
# Determine target install directory
target_dir = Path(install_dir) if install_dir else self.hoolamike_app_install_path
target_dir.mkdir(parents=True, exist_ok=True)
# Fetch latest release info
release_url = "https://api.github.com/repos/Niedzwiedzw/hoolamike/releases/latest"
self.logger.info(f"Fetching latest Hoolamike release info from {release_url}")
resp = requests.get(release_url, timeout=15, verify=True)
resp.raise_for_status()
data = resp.json()
release_tag = data.get("tag_name") or data.get("name")
linux_asset = None
for asset in data.get("assets", []):
name = asset.get("name", "").lower()
if "linux" in name and (name.endswith(".tar.gz") or name.endswith(".tgz") or name.endswith(".zip")) and ("x86_64" in name or "amd64" in name):
linux_asset = asset
break
if not linux_asset:
return False, "No suitable Linux x86_64 Hoolamike asset found in latest release"
download_url = linux_asset.get("browser_download_url")
asset_name = linux_asset.get("name")
if not download_url or not asset_name:
return False, "Latest release is missing required asset metadata"
# Download to target directory
temp_path = target_dir / asset_name
if not self.filesystem_handler.download_file(download_url, temp_path, overwrite=True, quiet=True):
return False, "Failed to download Hoolamike asset"
# Extract
try:
if asset_name.lower().endswith((".tar.gz", ".tgz")):
with tarfile.open(temp_path, "r:*") as tar:
tar.extractall(path=target_dir)
elif asset_name.lower().endswith(".zip"):
with zipfile.ZipFile(temp_path, "r") as zf:
zf.extractall(target_dir)
else:
return False, f"Unknown archive format: {asset_name}"
finally:
try:
temp_path.unlink(missing_ok=True) # cleanup
except Exception:
pass
# Ensure executable bit on binary
exe_path = target_dir / HOOLAMIKE_EXECUTABLE_NAME
if not exe_path.is_file():
# Some archives may include a subfolder; try to locate the binary
for p in target_dir.rglob(HOOLAMIKE_EXECUTABLE_NAME):
if p.is_file():
exe_path = p
break
if not exe_path.is_file():
return False, "Hoolamike binary not found after extraction"
try:
os.chmod(exe_path, 0o755)
except Exception as e:
self.logger.warning(f"Failed to chmod +x on {exe_path}: {e}")
# Mark installed and persist path
self.hoolamike_app_install_path = target_dir
self.hoolamike_executable_path = exe_path
self.hoolamike_installed = True
self.config_handler.set('hoolamike_install_path', str(target_dir))
if release_tag:
self.config_handler.set('hoolamike_version', str(release_tag))
self.config_handler.save_config()
return True, f"Hoolamike installed at {target_dir}"
except Exception as e:
self.logger.error("Hoolamike installation failed", exc_info=True)
return False, f"Error installing Hoolamike: {e}"
def get_installed_hoolamike_version(self) -> Optional[str]:
"""Return the installed Hoolamike version stored in Jackify config, if any."""
try:
v = self.config_handler.get('hoolamike_version')
return str(v) if v else None
except Exception:
return None
def is_hoolamike_update_available(self) -> tuple[bool, Optional[str], Optional[str]]:
"""
Check GitHub for the latest Hoolamike release and compare with installed version.
Returns (update_available, installed_version, latest_version).
"""
installed = self.get_installed_hoolamike_version()
try:
release_url = "https://api.github.com/repos/Niedzwiedzw/hoolamike/releases/latest"
resp = requests.get(release_url, timeout=10, verify=True)
resp.raise_for_status()
latest = resp.json().get('tag_name') or resp.json().get('name')
if not latest:
return (False, installed, None)
if not installed:
# No version recorded but installed may exist; treat as update available
return (True, None, latest)
return (installed != str(latest), installed, str(latest))
except Exception:
return (False, installed, None)
def install_update_hoolamike(self, context=None) -> bool:
"""Install or update Hoolamike application.
@@ -654,18 +780,89 @@ class HoolamikeHandler:
input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}")
return False
def install_ttw(self, ttw_mpi_path=None, ttw_output_path=None, context=None):
"""Install Tale of Two Wastelands (TTW) using Hoolamike.
def install_ttw_backend(self, ttw_mpi_path, ttw_output_path):
"""Clean backend function for TTW installation - no user interaction.
Args:
ttw_mpi_path: Path to the TTW installer .mpi file
ttw_output_path: Target installation directory for TTW
ttw_mpi_path: Path to the TTW installer .mpi file (required)
ttw_output_path: Target installation directory for TTW (required)
Returns:
tuple: (success: bool, message: str)
"""
self.logger.info(f"Starting Tale of Two Wastelands installation via Hoolamike")
# Validate required parameters
if not ttw_mpi_path or not ttw_output_path:
return False, "Missing required parameters: ttw_mpi_path and ttw_output_path are required"
# Convert to Path objects
ttw_mpi_path = Path(ttw_mpi_path)
ttw_output_path = Path(ttw_output_path)
# Validate paths exist
if not ttw_mpi_path.exists():
return False, f"TTW .mpi file not found: {ttw_mpi_path}"
if not ttw_output_path.exists():
try:
ttw_output_path.mkdir(parents=True, exist_ok=True)
except Exception as e:
return False, f"Failed to create output directory: {e}"
# Check Hoolamike installation
self._check_hoolamike_installation()
# Ensure config is loaded
if self.hoolamike_config is None:
loaded = self._load_hoolamike_config()
if not loaded or self.hoolamike_config is None:
self.logger.error("Failed to load or generate hoolamike.yaml configuration.")
return False, "Failed to load or generate Hoolamike configuration"
# Verify required games are detected
required_games = ['Fallout 3', 'Fallout New Vegas']
detected_games = self.path_handler.find_vanilla_game_paths()
missing_games = [game for game in required_games if game not in detected_games]
if missing_games:
self.logger.error(f"Missing required games for TTW installation: {', '.join(missing_games)}")
return False, f"Missing required games: {', '.join(missing_games)}. TTW requires both Fallout 3 and Fallout New Vegas."
# Update TTW configuration
self._update_hoolamike_config_for_ttw(ttw_mpi_path, ttw_output_path)
if not self.save_hoolamike_config():
self.logger.error("Failed to save hoolamike.yaml configuration.")
return False, "Failed to save Hoolamike configuration"
# Construct and execute command
cmd = [
str(self.hoolamike_executable_path),
"tale-of-two-wastelands"
]
self.logger.info(f"Executing Hoolamike command: {' '.join(cmd)}")
try:
ret = subprocess.call(cmd, cwd=str(self.hoolamike_app_install_path), env=get_clean_subprocess_env())
if ret == 0:
self.logger.info("TTW installation completed successfully.")
return True, "TTW installation completed successfully!"
else:
self.logger.error(f"TTW installation process returned non-zero exit code: {ret}")
return False, f"TTW installation failed with exit code {ret}"
except Exception as e:
self.logger.error(f"Error executing Hoolamike TTW installation: {e}", exc_info=True)
return False, f"Error executing Hoolamike TTW installation: {e}"
def install_ttw(self, ttw_mpi_path=None, ttw_output_path=None, context=None):
"""CLI interface for TTW installation - handles user interaction and calls backend.
Args:
ttw_mpi_path: Path to the TTW installer .mpi file (optional for CLI)
ttw_output_path: Target installation directory for TTW (optional for CLI)
Returns:
bool: True if successful, False otherwise
"""
self.logger.info(f"Starting Tale of Two Wastelands installation via Hoolamike")
self._check_hoolamike_installation()
menu = self.menu_handler
print(f"\n{'='*60}")
print(f"{COLOR_INFO}Hoolamike: Tale of Two Wastelands Installation{COLOR_RESET}")
@@ -676,123 +873,90 @@ class HoolamikeHandler:
print(f" • You must provide the path to your TTW .mpi installer file.")
print(f" • You must select an output directory for the TTW install.\n")
# Ensure config is loaded
if self.hoolamike_config is None:
loaded = self._load_hoolamike_config()
if not loaded or self.hoolamike_config is None:
self.logger.error("Failed to load or generate hoolamike.yaml configuration.")
print(f"{COLOR_ERROR}Error: Could not load or generate Hoolamike configuration. Aborting TTW install.{COLOR_RESET}")
return False
# Verify required games are in configuration
required_games = ['Fallout 3', 'Fallout New Vegas']
detected_games = self.path_handler.find_vanilla_game_paths()
missing_games = [game for game in required_games if game not in detected_games]
if missing_games:
self.logger.error(f"Missing required games for TTW installation: {', '.join(missing_games)}")
print(f"{COLOR_ERROR}Error: The following required games were not found: {', '.join(missing_games)}{COLOR_RESET}")
print("TTW requires both Fallout 3 and Fallout New Vegas to be installed.")
return False
# Prompt for TTW .mpi file
print(f"{COLOR_INFO}Please provide the path to your TTW .mpi installer file.{COLOR_RESET}")
print(f"You can download this from: {COLOR_INFO}https://mod.pub/ttw/133/files{COLOR_RESET}")
print(f"(Extract the .mpi file from the downloaded archive.)\n")
while not ttw_mpi_path:
candidate = menu.get_existing_file_path(
prompt_message="Enter the path to your TTW .mpi file (or 'q' to cancel):",
extension_filter=".mpi",
no_header=True
)
if candidate is None:
# If parameters provided, use them directly
if ttw_mpi_path and ttw_output_path:
print(f"{COLOR_INFO}Using provided parameters:{COLOR_RESET}")
print(f"- TTW .mpi file: {ttw_mpi_path}")
print(f"- Output directory: {ttw_output_path}")
print(f"{COLOR_WARNING}Proceed with these settings and start TTW installation? (This can take MANY HOURS){COLOR_RESET}")
confirm = input(f"{COLOR_PROMPT}[Y/n]: {COLOR_RESET}").strip().lower()
if confirm and not confirm.startswith('y'):
print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}")
return False
if str(candidate).strip().lower() == 'q':
else:
# Interactive mode - collect user input
print(f"{COLOR_INFO}Please provide the path to your TTW .mpi installer file.{COLOR_RESET}")
print(f"You can download this from: {COLOR_INFO}https://mod.pub/ttw/133/files{COLOR_RESET}")
print(f"(Extract the .mpi file from the downloaded archive.)\n")
while not ttw_mpi_path:
candidate = menu.get_existing_file_path(
prompt_message="Enter the path to your TTW .mpi file (or 'q' to cancel):",
extension_filter=".mpi",
no_header=True
)
if candidate is None:
print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}")
return False
if str(candidate).strip().lower() == 'q':
print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}")
return False
ttw_mpi_path = candidate
# Prompt for output directory
print(f"\n{COLOR_INFO}Please select the output directory where TTW will be installed.{COLOR_RESET}")
print(f"(This should be an empty or new directory.)\n")
while not ttw_output_path:
ttw_output_path = menu.get_directory_path(
prompt_message="Select the TTW output directory:",
default_path=self.hoolamike_app_install_path / "TTW_Output",
create_if_missing=True,
no_header=False
)
if not ttw_output_path:
print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}")
return False
if ttw_output_path.exists() and any(ttw_output_path.iterdir()):
print(f"{COLOR_WARNING}Warning: The selected directory '{ttw_output_path}' already exists and is not empty. Its contents may be overwritten!{COLOR_RESET}")
confirm = input(f"{COLOR_PROMPT}This directory is not empty and may be overwritten. Proceed? (y/N): {COLOR_RESET}").strip().lower()
if not confirm.startswith('y'):
print(f"{COLOR_INFO}Please select a different directory.\n{COLOR_RESET}")
ttw_output_path = None
continue
# Summary & Confirmation
print(f"\n{'-'*60}")
print(f"{COLOR_INFO}Summary of configuration:{COLOR_RESET}")
print(f"- TTW .mpi file: {ttw_mpi_path}")
print(f"- Output directory: {ttw_output_path}")
print(f"{'-'*60}")
print(f"{COLOR_WARNING}Proceed with these settings and start TTW installation? (This can take MANY HOURS){COLOR_RESET}")
confirm = input(f"{COLOR_PROMPT}[Y/n]: {COLOR_RESET}").strip().lower()
if confirm and not confirm.startswith('y'):
print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}")
return False
ttw_mpi_path = candidate
# Prompt for output directory
print(f"\n{COLOR_INFO}Please select the output directory where TTW will be installed.{COLOR_RESET}")
print(f"(This should be an empty or new directory.)\n")
while not ttw_output_path:
ttw_output_path = menu.get_directory_path(
prompt_message="Select the TTW output directory:",
default_path=self.hoolamike_app_install_path / "TTW_Output",
create_if_missing=True,
no_header=False
)
if not ttw_output_path:
print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}")
return False
if ttw_output_path.exists() and any(ttw_output_path.iterdir()):
print(f"{COLOR_WARNING}Warning: The selected directory '{ttw_output_path}' already exists and is not empty. Its contents may be overwritten!{COLOR_RESET}")
confirm = input(f"{COLOR_PROMPT}This directory is not empty and may be overwritten. Proceed? (y/N): {COLOR_RESET}").strip().lower()
if not confirm.startswith('y'):
print(f"{COLOR_INFO}Please select a different directory.\n{COLOR_RESET}")
ttw_output_path = None
continue
# Call the clean backend function
success, message = self.install_ttw_backend(ttw_mpi_path, ttw_output_path)
# --- Summary & Confirmation ---
print(f"\n{'-'*60}")
print(f"{COLOR_INFO}Summary of configuration:{COLOR_RESET}")
print(f"- TTW .mpi file: {ttw_mpi_path}")
print(f"- Output directory: {ttw_output_path}")
print("- Games:")
for game in required_games:
found = detected_games.get(game)
print(f" {game}: {found if found else 'Not Found'}")
print(f"{'-'*60}")
print(f"{COLOR_WARNING}Proceed with these settings and start TTW installation? (This can take MANY HOURS){COLOR_RESET}")
confirm = input(f"{COLOR_PROMPT}[Y/n]: {COLOR_RESET}").strip().lower()
if confirm and not confirm.startswith('y'):
print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}")
return False
if success:
print(f"\n{COLOR_SUCCESS}{message}{COLOR_RESET}")
# --- Always re-detect games before updating config ---
detected_games = self.path_handler.find_vanilla_game_paths()
if not detected_games:
print(f"{COLOR_ERROR}No supported games were detected on your system. TTW requires Fallout 3 and Fallout New Vegas to be installed.{COLOR_RESET}")
return False
# Update the games section with correct keys
if self.hoolamike_config is None:
self.hoolamike_config = {}
self.hoolamike_config['games'] = {
self._format_game_name(game): {"root_directory": str(path)}
for game, path in detected_games.items()
}
# Offer to create MO2 zip archive
print(f"\n{COLOR_INFO}Would you like to create a zipped mod archive for MO2?{COLOR_RESET}")
print(f"This will package the TTW files for easy installation into Mod Organizer 2.")
create_zip = input(f"{COLOR_PROMPT}Create zip archive? [Y/n]: {COLOR_RESET}").strip().lower()
# Update TTW configuration
self._update_hoolamike_config_for_ttw(ttw_mpi_path, ttw_output_path)
if not self.save_hoolamike_config():
self.logger.error("Failed to save hoolamike.yaml configuration.")
print(f"{COLOR_ERROR}Error: Failed to save Hoolamike configuration.{COLOR_RESET}")
print("Attempting to continue anyway...")
# Construct command to execute
cmd = [
str(self.hoolamike_executable_path),
"tale-of-two-wastelands"
]
self.logger.info(f"Executing Hoolamike command: {' '.join(cmd)}")
print(f"\n{COLOR_INFO}Executing Hoolamike for TTW Installation...{COLOR_RESET}")
print(f"Command: {' '.join(cmd)}")
print(f"{COLOR_INFO}Streaming output below. Press Ctrl+C to cancel and return to Jackify menu.{COLOR_RESET}\n")
try:
ret = subprocess.call(cmd, cwd=str(self.hoolamike_app_install_path), env=get_clean_subprocess_env())
if ret == 0:
self.logger.info("TTW installation completed successfully.")
print(f"\n{COLOR_SUCCESS}TTW installation completed successfully!{COLOR_RESET}")
input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}")
return True
if not create_zip or create_zip.startswith('y'):
zip_success = self._create_ttw_mod_archive_cli(ttw_mpi_path, ttw_output_path)
if not zip_success:
print(f"\n{COLOR_WARNING}Archive creation failed, but TTW installation completed successfully.{COLOR_RESET}")
else:
self.logger.error(f"TTW installation process returned non-zero exit code: {ret}")
print(f"\n{COLOR_ERROR}Error: TTW installation failed with exit code {ret}.{COLOR_RESET}")
input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}")
return False
except Exception as e:
self.logger.error(f"Error executing Hoolamike TTW installation: {e}", exc_info=True)
print(f"\n{COLOR_ERROR}Error executing Hoolamike TTW installation: {e}{COLOR_RESET}")
print(f"\n{COLOR_INFO}Skipping archive creation. You can manually use the TTW files from the output directory.{COLOR_RESET}")
input(f"\n{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}")
return True
else:
print(f"\n{COLOR_ERROR}{message}{COLOR_RESET}")
input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}")
return False
@@ -818,27 +982,125 @@ class HoolamikeHandler:
# Set destination variable
ttw_config["variables"]["DESTINATION"] = str(ttw_output_path)
# Set USERPROFILE to a Jackify-managed directory for TTW
userprofile_path = str(self.hoolamike_app_install_path / "USERPROFILE")
# Set USERPROFILE to Fallout New Vegas Wine prefix Documents folder
userprofile_path = self._detect_fallout_nv_userprofile()
if "variables" not in self.hoolamike_config["extras"]["tale_of_two_wastelands"]:
self.hoolamike_config["extras"]["tale_of_two_wastelands"]["variables"] = {}
self.hoolamike_config["extras"]["tale_of_two_wastelands"]["variables"]["USERPROFILE"] = userprofile_path
# Make sure game paths are set correctly
# Make sure game paths are set correctly using proper Hoolamike naming format
for game in ['Fallout 3', 'Fallout New Vegas']:
if game in self.game_install_paths:
game_key = game.replace(' ', '').lower()
# Use _format_game_name to ensure correct naming (removes spaces)
formatted_game_name = self._format_game_name(game)
if "games" not in self.hoolamike_config:
self.hoolamike_config["games"] = {}
if game not in self.hoolamike_config["games"]:
self.hoolamike_config["games"][game] = {}
self.hoolamike_config["games"][game]["root_directory"] = str(self.game_install_paths[game])
if formatted_game_name not in self.hoolamike_config["games"]:
self.hoolamike_config["games"][formatted_game_name] = {}
self.hoolamike_config["games"][formatted_game_name]["root_directory"] = str(self.game_install_paths[game])
self.logger.info("Updated Hoolamike configuration with TTW settings.")
def _create_ttw_mod_archive_cli(self, ttw_mpi_path: Path, ttw_output_path: Path) -> bool:
"""Create a zipped mod archive of TTW output for MO2 installation (CLI version).
Args:
ttw_mpi_path: Path to the TTW .mpi file (used for version extraction)
ttw_output_path: Path to the TTW output directory to archive
Returns:
bool: True if successful, False otherwise
"""
try:
import shutil
import re
if not ttw_output_path.exists():
print(f"{COLOR_ERROR}Output directory does not exist: {ttw_output_path}{COLOR_RESET}")
return False
# Extract version from .mpi filename (e.g., "TTW v3.4.mpi" -> "3.4")
version_suffix = ""
if ttw_mpi_path:
mpi_filename = ttw_mpi_path.stem # Get filename without extension
# Look for version pattern like "3.4", "v3.4", etc.
version_match = re.search(r'v?(\d+\.\d+(?:\.\d+)?)', mpi_filename, re.IGNORECASE)
if version_match:
version_suffix = f" {version_match.group(1)}"
# Create archive filename - [NoDelete] prefix is used by MO2 workflows
archive_name = f"[NoDelete] Tale of Two Wastelands{version_suffix}"
# Place archive in parent directory of output
archive_path = ttw_output_path.parent / archive_name
print(f"\n{COLOR_INFO}Creating mod archive: {archive_name}.zip{COLOR_RESET}")
print(f"{COLOR_INFO}This may take several minutes...{COLOR_RESET}")
# Create the zip archive
# shutil.make_archive returns the path without .zip extension
final_archive = shutil.make_archive(
str(archive_path), # base name (without extension)
'zip', # format
str(ttw_output_path) # directory to archive
)
print(f"\n{COLOR_SUCCESS}Archive created successfully: {Path(final_archive).name}{COLOR_RESET}")
print(f"{COLOR_INFO}Location: {final_archive}{COLOR_RESET}")
print(f"{COLOR_INFO}You can now install this archive as a mod in MO2.{COLOR_RESET}")
self.logger.info(f"Created TTW mod archive: {final_archive}")
return True
except Exception as e:
print(f"\n{COLOR_ERROR}Failed to create mod archive: {e}{COLOR_RESET}")
self.logger.error(f"Failed to create TTW mod archive: {e}", exc_info=True)
return False
def _detect_fallout_nv_userprofile(self) -> str:
"""
Detect the Fallout New Vegas Wine prefix Documents folder for USERPROFILE.
Returns:
str: Path to the Fallout New Vegas Wine prefix Documents folder,
or fallback to Jackify-managed directory if not found.
"""
try:
# Fallout New Vegas AppID
fnv_appid = "22380"
# Find the compatdata directory for Fallout New Vegas
compatdata_path = self.path_handler.find_compat_data(fnv_appid)
if not compatdata_path:
self.logger.warning(f"Could not find compatdata directory for Fallout New Vegas (AppID: {fnv_appid})")
# Fallback to Jackify-managed directory
fallback_path = str(self.hoolamike_app_install_path / "USERPROFILE")
self.logger.info(f"Using fallback USERPROFILE path: {fallback_path}")
return fallback_path
# Construct the Wine prefix Documents path
wine_documents_path = compatdata_path / "pfx" / "drive_c" / "users" / "steamuser" / "Documents" / "My Games" / "FalloutNV"
if wine_documents_path.exists():
self.logger.info(f"Found Fallout New Vegas Wine prefix Documents folder: {wine_documents_path}")
return str(wine_documents_path)
else:
self.logger.warning(f"Fallout New Vegas Wine prefix Documents folder not found at: {wine_documents_path}")
# Fallback to Jackify-managed directory
fallback_path = str(self.hoolamike_app_install_path / "USERPROFILE")
self.logger.info(f"Using fallback USERPROFILE path: {fallback_path}")
return fallback_path
except Exception as e:
self.logger.error(f"Error detecting Fallout New Vegas USERPROFILE: {e}", exc_info=True)
# Fallback to Jackify-managed directory
fallback_path = str(self.hoolamike_app_install_path / "USERPROFILE")
self.logger.info(f"Using fallback USERPROFILE path: {fallback_path}")
return fallback_path
def reset_config(self):
"""Resets the hoolamike.yaml to default settings, backing up any existing file."""
if self.hoolamike_config_path.is_file():
@@ -973,6 +1235,165 @@ class HoolamikeHandler:
self.logger.error(f"Error launching or waiting for editor: {e}")
print(f"{COLOR_ERROR}An error occurred while launching the editor: {e}{COLOR_RESET}")
@staticmethod
def integrate_ttw_into_modlist(ttw_output_path: Path, modlist_install_dir: Path, ttw_version: str) -> bool:
"""Integrate TTW output into a modlist's MO2 structure
This method:
1. Copies TTW output to the modlist's mods folder
2. Updates modlist.txt for all profiles
3. Updates plugins.txt with TTW ESMs in correct order
Args:
ttw_output_path: Path to TTW output directory
modlist_install_dir: Path to modlist installation directory
ttw_version: TTW version string (e.g., "3.4")
Returns:
bool: True if integration successful, False otherwise
"""
logging_handler = LoggingHandler()
logging_handler.rotate_log_for_logger('ttw-install', 'TTW_Install_workflow.log')
logger = logging_handler.setup_logger('ttw-install', 'TTW_Install_workflow.log')
try:
import shutil
import re
# Validate paths
if not ttw_output_path.exists():
logger.error(f"TTW output path does not exist: {ttw_output_path}")
return False
mods_dir = modlist_install_dir / "mods"
profiles_dir = modlist_install_dir / "profiles"
if not mods_dir.exists() or not profiles_dir.exists():
logger.error(f"Invalid modlist directory structure: {modlist_install_dir}")
return False
# Create mod folder name with version
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
# Copy TTW output to mods directory
logger.info(f"Copying TTW output to {target_mod_dir}")
if target_mod_dir.exists():
logger.info(f"Removing existing TTW mod at {target_mod_dir}")
shutil.rmtree(target_mod_dir)
shutil.copytree(ttw_output_path, target_mod_dir)
logger.info("TTW output copied successfully")
# TTW ESMs in correct load order
ttw_esms = [
"Fallout3.esm",
"Anchorage.esm",
"ThePitt.esm",
"BrokenSteel.esm",
"PointLookout.esm",
"Zeta.esm",
"TaleOfTwoWastelands.esm",
"YUPTTW.esm"
]
# Process each profile
for profile_dir in profiles_dir.iterdir():
if not profile_dir.is_dir():
continue
profile_name = profile_dir.name
logger.info(f"Processing profile: {profile_name}")
# Update modlist.txt
modlist_file = profile_dir / "modlist.txt"
if modlist_file.exists():
# Read existing modlist
with open(modlist_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
# Find the TTW placeholder separator and insert BEFORE it
separator_found = False
ttw_mod_line = f"+{mod_folder_name}\n"
new_lines = []
for line in lines:
# Skip existing TTW mod entries (but keep separators and other TTW-related mods)
# Match patterns: "+[NoDelete] Tale of Two Wastelands", "+[NoDelete] TTW", etc.
stripped = line.strip()
if stripped.startswith('+') and '[nodelete]' in stripped.lower():
# Check if it's the main TTW mod (not other TTW-related mods like "TTW Quick Start")
if ('tale of two wastelands' in stripped.lower() and 'quick start' not in stripped.lower() and
'loading wheel' not in stripped.lower()) or stripped.lower().startswith('+[nodelete] ttw '):
logger.info(f"Removing existing TTW mod entry: {stripped}")
continue
# Insert TTW mod BEFORE the placeholder separator (MO2 order is bottom-up)
# Check BEFORE appending so TTW mod appears before separator in file
if "put tale of two wastelands mod here" in line.lower() and "_separator" in line.lower():
new_lines.append(ttw_mod_line)
separator_found = True
logger.info(f"Inserted TTW mod before separator: {line.strip()}")
new_lines.append(line)
# If no separator found, append at the end
if not separator_found:
new_lines.append(ttw_mod_line)
logger.warning(f"No TTW separator found in {profile_name}, appended to end")
# Write back
with open(modlist_file, 'w', encoding='utf-8') as f:
f.writelines(new_lines)
logger.info(f"Updated modlist.txt for {profile_name}")
else:
logger.warning(f"modlist.txt not found for profile {profile_name}")
# Update plugins.txt
plugins_file = profile_dir / "plugins.txt"
if plugins_file.exists():
# Read existing plugins
with open(plugins_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
# Remove any existing TTW ESMs
ttw_esm_set = set(esm.lower() for esm in ttw_esms)
lines = [line for line in lines if line.strip().lower() not in ttw_esm_set]
# Find CaravanPack.esm and insert TTW ESMs after it
insert_index = None
for i, line in enumerate(lines):
if line.strip().lower() == "caravanpack.esm":
insert_index = i + 1
break
if insert_index is not None:
# Insert TTW ESMs in correct order
for esm in reversed(ttw_esms):
lines.insert(insert_index, f"{esm}\n")
else:
logger.warning(f"CaravanPack.esm not found in {profile_name}, appending TTW ESMs to end")
for esm in ttw_esms:
lines.append(f"{esm}\n")
# Write back
with open(plugins_file, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info(f"Updated plugins.txt for {profile_name}")
else:
logger.warning(f"plugins.txt not found for profile {profile_name}")
logger.info("TTW integration completed successfully")
return True
except Exception as e:
logger.error(f"Failed to integrate TTW into modlist: {e}")
import traceback
logger.error(traceback.format_exc())
return False
# Example usage (for testing, remove later)
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')

View File

@@ -689,25 +689,6 @@ class ModlistHandler:
return False
self.logger.info("Step 3: Curated user.reg.modlist and system.reg.modlist applied successfully.")
# Step 3.5: Apply universal dotnet4.x compatibility registry fixes
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Applying universal dotnet4.x compatibility fixes")
self.logger.info("Step 3.5: Applying universal dotnet4.x compatibility registry fixes...")
registry_success = False
try:
registry_success = self._apply_universal_dotnet_fixes()
except Exception as e:
self.logger.error(f"CRITICAL: Registry fixes failed - modlist may have .NET compatibility issues: {e}")
registry_success = False
if not registry_success:
self.logger.error("=" * 80)
self.logger.error("WARNING: Universal dotnet4.x registry fixes FAILED!")
self.logger.error("This modlist may experience .NET Framework compatibility issues.")
self.logger.error("Consider manually setting mscoree=native in winecfg if problems occur.")
self.logger.error("=" * 80)
# Continue but user should be aware of potential issues
# Step 4: Install Wine Components
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Installing Wine components (this may take a while)")
@@ -751,6 +732,26 @@ class ModlistHandler:
return False
self.logger.info("Step 4: Installing Wine components... Done")
# Step 4.5: Apply universal dotnet4.x compatibility registry fixes AFTER wine components
# This ensures the fixes are not overwritten by component installation processes
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Applying universal dotnet4.x compatibility fixes")
self.logger.info("Step 4.5: Applying universal dotnet4.x compatibility registry fixes...")
registry_success = False
try:
registry_success = self._apply_universal_dotnet_fixes()
except Exception as e:
self.logger.error(f"CRITICAL: Registry fixes failed - modlist may have .NET compatibility issues: {e}")
registry_success = False
if not registry_success:
self.logger.error("=" * 80)
self.logger.error("WARNING: Universal dotnet4.x registry fixes FAILED!")
self.logger.error("This modlist may experience .NET Framework compatibility issues.")
self.logger.error("Consider manually setting mscoree=native in winecfg if problems occur.")
self.logger.error("=" * 80)
# Continue but user should be aware of potential issues
# Step 5: Ensure permissions of Modlist directory
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Setting ownership and permissions for modlist directory")
@@ -1528,14 +1529,18 @@ class ModlistHandler:
return False
def _apply_universal_dotnet_fixes(self):
"""Apply universal dotnet4.x compatibility registry fixes to ALL modlists"""
"""
Apply universal dotnet4.x compatibility registry fixes to ALL modlists.
Now called AFTER wine component installation to prevent overwrites.
Includes wineserver shutdown/flush to ensure persistence.
"""
try:
prefix_path = os.path.join(str(self.compat_data_path), "pfx")
if not os.path.exists(prefix_path):
self.logger.warning(f"Prefix path not found: {prefix_path}")
return False
self.logger.info("Applying universal dotnet4.x compatibility registry fixes...")
self.logger.info("Applying universal dotnet4.x compatibility registry fixes (post-component installation)...")
# Find the appropriate Wine binary to use for registry operations
wine_binary = self._find_wine_binary_for_registry()
@@ -1543,11 +1548,27 @@ class ModlistHandler:
self.logger.error("Could not find Wine binary for registry operations")
return False
# Find wineserver binary for flushing registry changes
wine_dir = os.path.dirname(wine_binary)
wineserver_binary = os.path.join(wine_dir, 'wineserver')
if not os.path.exists(wineserver_binary):
self.logger.warning(f"wineserver not found at {wineserver_binary}, registry flush may not work")
wineserver_binary = None
# Set environment for Wine registry operations
env = os.environ.copy()
env['WINEPREFIX'] = prefix_path
env['WINEDEBUG'] = '-all' # Suppress Wine debug output
# Shutdown any running wineserver processes to ensure clean slate
if wineserver_binary:
self.logger.debug("Shutting down wineserver before applying registry fixes...")
try:
subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True)
self.logger.debug("Wineserver shutdown complete")
except Exception as e:
self.logger.warning(f"Wineserver shutdown failed (non-critical): {e}")
# Registry fix 1: Set mscoree=native DLL override
# This tells Wine to use native .NET runtime instead of Wine's implementation
self.logger.debug("Setting mscoree=native DLL override...")
@@ -1557,7 +1578,7 @@ class ModlistHandler:
'/v', 'mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
]
result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True)
result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30)
if result1.returncode == 0:
self.logger.info("Successfully applied mscoree=native DLL override")
else:
@@ -1572,18 +1593,57 @@ class ModlistHandler:
'/v', 'OnlyUseLatestCLR', '/t', 'REG_DWORD', '/d', '1', '/f'
]
result2 = subprocess.run(cmd2, env=env, capture_output=True, text=True)
result2 = subprocess.run(cmd2, env=env, capture_output=True, text=True, errors='replace', timeout=30)
if result2.returncode == 0:
self.logger.info("Successfully applied OnlyUseLatestCLR=1 registry entry")
else:
self.logger.warning(f"Failed to set OnlyUseLatestCLR: {result2.stderr}")
# Both fixes applied - this should eliminate dotnet4.x installation requirements
if result1.returncode == 0 and result2.returncode == 0:
self.logger.info("Universal dotnet4.x compatibility fixes applied successfully")
# Force wineserver to flush registry changes to disk
if wineserver_binary:
self.logger.debug("Flushing registry changes to disk via wineserver shutdown...")
try:
subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True)
self.logger.debug("Registry changes flushed to disk")
except Exception as e:
self.logger.warning(f"Registry flush failed (non-critical): {e}")
# VERIFICATION: Confirm the registry entries persisted
self.logger.info("Verifying registry entries were applied and persisted...")
verification_passed = True
# Verify mscoree=native
verify_cmd1 = [
wine_binary, 'reg', 'query',
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
'/v', 'mscoree'
]
verify_result1 = subprocess.run(verify_cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30)
if verify_result1.returncode == 0 and 'native' in verify_result1.stdout:
self.logger.info("VERIFIED: mscoree=native is set correctly")
else:
self.logger.error(f"VERIFICATION FAILED: mscoree=native not found in registry. Query output: {verify_result1.stdout}")
verification_passed = False
# Verify OnlyUseLatestCLR=1
verify_cmd2 = [
wine_binary, 'reg', 'query',
'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework',
'/v', 'OnlyUseLatestCLR'
]
verify_result2 = subprocess.run(verify_cmd2, env=env, capture_output=True, text=True, errors='replace', timeout=30)
if verify_result2.returncode == 0 and ('0x1' in verify_result2.stdout or 'REG_DWORD' in verify_result2.stdout):
self.logger.info("VERIFIED: OnlyUseLatestCLR=1 is set correctly")
else:
self.logger.error(f"VERIFICATION FAILED: OnlyUseLatestCLR=1 not found in registry. Query output: {verify_result2.stdout}")
verification_passed = False
# Both fixes applied and verified
if result1.returncode == 0 and result2.returncode == 0 and verification_passed:
self.logger.info("Universal dotnet4.x compatibility fixes applied, flushed, and verified successfully")
return True
else:
self.logger.warning("Some dotnet4.x registry fixes failed, but continuing...")
self.logger.error("Registry fixes failed verification - fixes may not persist across prefix restarts")
return False
except Exception as e:

View File

@@ -945,6 +945,9 @@ class ModlistInstallCLI:
if configuration_success:
self.logger.info("Post-installation configuration completed successfully")
# Check for TTW integration eligibility
self._check_and_prompt_ttw_integration(install_dir_str, detected_game, modlist_name)
else:
self.logger.warning("Post-installation configuration had issues")
else:
@@ -1134,5 +1137,159 @@ class ModlistInstallCLI:
# Add URL on next line for easier debugging
return f"{line}\n Nexus URL: {mod_url}"
return line
return line
def _check_and_prompt_ttw_integration(self, install_dir: str, game_type: str, modlist_name: str):
"""Check if modlist is eligible for TTW integration and prompt user"""
try:
# Check eligibility: FNV game, TTW-compatible modlist, no existing TTW
if not self._is_ttw_eligible(install_dir, game_type, modlist_name):
return
# Prompt user for TTW installation
print(f"\n{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
print(f"{COLOR_INFO}TTW Integration Available{COLOR_RESET}")
print(f"{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
print(f"\nThis modlist ({modlist_name}) supports Tale of Two Wastelands (TTW).")
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()
if user_input in ['yes', 'y']:
self._launch_ttw_installation(modlist_name, install_dir)
else:
print(f"{COLOR_INFO}Skipping TTW installation. You can install it later from the main menu.{COLOR_RESET}")
except Exception as e:
self.logger.error(f"Error during TTW eligibility check: {e}", exc_info=True)
def _is_ttw_eligible(self, install_dir: str, game_type: str, modlist_name: str) -> bool:
"""Check if modlist is eligible for TTW integration"""
try:
from pathlib import Path
# Check 1: Must be Fallout New Vegas
if not game_type or game_type.lower() not in ['falloutnv', 'fallout new vegas', 'fallout_new_vegas']:
return False
# Check 2: Must be on TTW compatibility whitelist
from jackify.backend.data.ttw_compatible_modlists import is_ttw_compatible
if not is_ttw_compatible(modlist_name):
return False
# Check 3: TTW must not already be installed
if self._detect_existing_ttw(install_dir):
self.logger.info(f"TTW already installed in {install_dir}, skipping prompt")
return False
return True
except Exception as e:
self.logger.error(f"Error checking TTW eligibility: {e}")
return False
def _detect_existing_ttw(self, install_dir: str) -> bool:
"""Detect if TTW is already installed in the modlist"""
try:
from pathlib import Path
install_path = Path(install_dir)
# Search for TTW indicators in common locations
search_paths = [
install_path,
install_path / "mods",
install_path / "Stock Game",
install_path / "Game Root"
]
for search_path in search_paths:
if not search_path.exists():
continue
# Look for folders containing "tale" and "two" and "wastelands"
for folder in search_path.iterdir():
if not folder.is_dir():
continue
folder_name_lower = folder.name.lower()
if all(keyword in folder_name_lower for keyword in ['tale', 'two', 'wastelands']):
# Verify it has the TTW ESM file
for file in folder.rglob('*.esm'):
if 'taleoftwowastelands' in file.name.lower():
self.logger.info(f"Found existing TTW installation: {file}")
return True
return False
except Exception as e:
self.logger.error(f"Error detecting existing TTW: {e}")
return False
def _launch_ttw_installation(self, modlist_name: str, install_dir: str):
"""Launch TTW installation workflow"""
try:
print(f"\n{COLOR_INFO}Starting TTW installation workflow...{COLOR_RESET}")
# Import TTW installation handler
from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler
from jackify.backend.models.configuration import SystemInfo
system_info = SystemInfo()
hoolamike_handler = HoolamikeHandler(system_info)
# Check if Hoolamike is installed
is_installed, installed_version = hoolamike_handler.check_installation_status()
if not is_installed:
print(f"{COLOR_INFO}Hoolamike (TTW installer) is not installed.{COLOR_RESET}")
user_input = input(f"{COLOR_PROMPT}Install Hoolamike? (yes/no): {COLOR_RESET}").strip().lower()
if user_input not in ['yes', 'y']:
print(f"{COLOR_INFO}TTW installation cancelled.{COLOR_RESET}")
return
# Install Hoolamike
print(f"{COLOR_INFO}Installing Hoolamike...{COLOR_RESET}")
success, message = hoolamike_handler.install_hoolamike()
if not success:
print(f"{COLOR_ERROR}Failed to install Hoolamike: {message}{COLOR_RESET}")
return
print(f"{COLOR_INFO}Hoolamike installed successfully.{COLOR_RESET}")
# Get Hoolamike MPI path
mpi_path = hoolamike_handler.get_mpi_path()
if not mpi_path or not os.path.exists(mpi_path):
print(f"{COLOR_ERROR}Hoolamike MPI file not found at: {mpi_path}{COLOR_RESET}")
return
# Prompt for TTW installation directory
print(f"\n{COLOR_PROMPT}TTW Installation Directory{COLOR_RESET}")
print(f"Default: {os.path.join(install_dir, 'TTW')}")
ttw_install_dir = input(f"{COLOR_PROMPT}TTW install directory (Enter for default): {COLOR_RESET}").strip()
if not ttw_install_dir:
ttw_install_dir = os.path.join(install_dir, "TTW")
# Run Hoolamike installation
print(f"\n{COLOR_INFO}Installing TTW using Hoolamike...{COLOR_RESET}")
print(f"{COLOR_INFO}This may take a while (15-30 minutes depending on your system).{COLOR_RESET}")
success = hoolamike_handler.run_hoolamike_install(mpi_path, ttw_install_dir)
if success:
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"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}")
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}")

View File

@@ -676,10 +676,10 @@ class PathHandler:
# For each library path, look for each target game
for library_path in library_paths:
# Check if the common directory exists
common_dir = library_path / "common"
# Check if the common directory exists (games are in steamapps/common)
common_dir = library_path / "steamapps" / "common"
if not common_dir.is_dir():
logger.debug(f"No 'common' directory in library: {library_path}")
logger.debug(f"No 'steamapps/common' directory in library: {library_path}")
continue
# Get subdirectories in common dir
@@ -694,8 +694,8 @@ class PathHandler:
if game_name in results:
continue # Already found this game
# Try to find by appmanifest
appmanifest_path = library_path / f"appmanifest_{app_id}.acf"
# Try to find by appmanifest (manifests are in steamapps subdirectory)
appmanifest_path = library_path / "steamapps" / f"appmanifest_{app_id}.acf"
if appmanifest_path.is_file():
# Find the installdir value
try:
@@ -799,7 +799,8 @@ class PathHandler:
match = re.match(sdcard_pattern, existing_game_path)
if match:
stripped_path = match.group(1) # Just the /Games/... part
new_gamepath_value = f"D:\\\\{stripped_path.replace('/', '\\\\')}"
windows_path = stripped_path.replace('/', '\\\\')
new_gamepath_value = f"D:\\\\{windows_path}"
new_gamepath_line = f"gamePath = @ByteArray({new_gamepath_value})\n"
logger.info(f"Updating gamePath for SD card: {lines[gamepath_line_index].strip()} -> {new_gamepath_line.strip()}")

View File

@@ -291,6 +291,9 @@ class WinetricksHandler:
# For non-dotnet40 installations, install all components together (faster)
max_attempts = 3
winetricks_failed = False
last_error_details = None
for attempt in range(1, max_attempts + 1):
if attempt > 1:
self.logger.warning(f"Retrying component installation (attempt {attempt}/{max_attempts})...")
@@ -301,9 +304,40 @@ class WinetricksHandler:
cmd = [self.winetricks_path, '--unattended'] + components_to_install
self.logger.debug(f"Running: {' '.join(cmd)}")
self.logger.debug(f"Environment WINE={env.get('WINE', 'NOT SET')}")
self.logger.debug(f"Environment DISPLAY={env.get('DISPLAY', 'NOT SET')}")
self.logger.debug(f"Environment WINEPREFIX={env.get('WINEPREFIX', 'NOT SET')}")
# Enhanced diagnostics for bundled winetricks
self.logger.debug("=== Winetricks Environment Diagnostics ===")
self.logger.debug(f"Bundled winetricks path: {self.winetricks_path}")
self.logger.debug(f"Winetricks exists: {os.path.exists(self.winetricks_path)}")
self.logger.debug(f"Winetricks executable: {os.access(self.winetricks_path, os.X_OK)}")
if os.path.exists(self.winetricks_path):
try:
winetricks_stat = os.stat(self.winetricks_path)
self.logger.debug(f"Winetricks permissions: {oct(winetricks_stat.st_mode)}")
self.logger.debug(f"Winetricks size: {winetricks_stat.st_size} bytes")
except Exception as stat_err:
self.logger.debug(f"Could not stat winetricks: {stat_err}")
self.logger.debug(f"WINE binary: {env.get('WINE', 'NOT SET')}")
wine_binary = env.get('WINE', '')
if wine_binary and os.path.exists(wine_binary):
self.logger.debug(f"WINE binary exists: True")
else:
self.logger.debug(f"WINE binary exists: False")
self.logger.debug(f"WINEPREFIX: {env.get('WINEPREFIX', 'NOT SET')}")
wineprefix = env.get('WINEPREFIX', '')
if wineprefix and os.path.exists(wineprefix):
self.logger.debug(f"WINEPREFIX exists: True")
self.logger.debug(f"WINEPREFIX/pfx exists: {os.path.exists(os.path.join(wineprefix, 'pfx'))}")
else:
self.logger.debug(f"WINEPREFIX exists: False")
self.logger.debug(f"DISPLAY: {env.get('DISPLAY', 'NOT SET')}")
self.logger.debug(f"WINETRICKS_CACHE: {env.get('WINETRICKS_CACHE', 'NOT SET')}")
self.logger.debug(f"Components to install: {components_to_install}")
self.logger.debug("==========================================")
result = subprocess.run(
cmd,
env=env,
@@ -337,14 +371,92 @@ class WinetricksHandler:
except Exception as e:
self.logger.warning(f"Could not read winetricks.log: {e}")
# Store detailed error information for fallback diagnostics
last_error_details = {
'returncode': result.returncode,
'stdout': result.stdout.strip(),
'stderr': result.stderr.strip(),
'attempt': attempt
}
self.logger.error(f"Winetricks command failed (Attempt {attempt}/{max_attempts}). Return Code: {result.returncode}")
self.logger.error(f"Stdout: {result.stdout.strip()}")
self.logger.error(f"Stderr: {result.stderr.strip()}")
# Enhanced error diagnostics with actionable information
stderr_lower = result.stderr.lower()
stdout_lower = result.stdout.lower()
if "command not found" in stderr_lower or "no such file" in stderr_lower:
self.logger.error("DIAGNOSTIC: Winetricks or dependency binary not found")
self.logger.error(" - Bundled winetricks may be missing dependencies")
self.logger.error(" - Will attempt protontricks fallback if all attempts fail")
elif "permission denied" in stderr_lower:
self.logger.error("DIAGNOSTIC: Permission issue detected")
self.logger.error(f" - Check permissions on: {self.winetricks_path}")
self.logger.error(f" - Check permissions on WINEPREFIX: {env.get('WINEPREFIX', 'N/A')}")
elif "timeout" in stderr_lower:
self.logger.error("DIAGNOSTIC: Timeout issue detected during component download/install")
elif "sha256sum mismatch" in stderr_lower or "sha256sum" in stdout_lower:
self.logger.error("DIAGNOSTIC: Checksum verification failed")
self.logger.error(" - Component download may be corrupted")
self.logger.error(" - Network issue or upstream file change")
elif "curl" in stderr_lower or "wget" in stderr_lower:
self.logger.error("DIAGNOSTIC: Download tool (curl/wget) issue")
self.logger.error(" - Network connectivity problem or missing download tool")
elif "cabextract" in stderr_lower:
self.logger.error("DIAGNOSTIC: cabextract missing or failed")
self.logger.error(" - Required for extracting Windows cabinet files")
elif "unzip" in stderr_lower:
self.logger.error("DIAGNOSTIC: unzip missing or failed")
self.logger.error(" - Required for extracting zip archives")
else:
self.logger.error("DIAGNOSTIC: Unknown winetricks failure")
self.logger.error(" - Check full logs for details")
self.logger.error(" - Will attempt protontricks fallback if all attempts fail")
winetricks_failed = True
except subprocess.TimeoutExpired as e:
self.logger.error(f"Winetricks timed out (Attempt {attempt}/{max_attempts}): {e}")
last_error_details = {'error': 'timeout', 'attempt': attempt}
winetricks_failed = True
except Exception as e:
self.logger.error(f"Error during winetricks run (Attempt {attempt}/{max_attempts}): {e}", exc_info=True)
last_error_details = {'error': str(e), 'attempt': attempt}
winetricks_failed = True
# All winetricks attempts failed - try automatic fallback to protontricks
if winetricks_failed:
self.logger.error(f"Winetricks failed after {max_attempts} attempts.")
# Check if protontricks is available for fallback
try:
protontricks_check = subprocess.run(['which', 'protontricks'],
capture_output=True, text=True, timeout=5)
if protontricks_check.returncode == 0:
self.logger.warning("=" * 80)
self.logger.warning("AUTOMATIC FALLBACK: Winetricks failed, attempting protontricks fallback...")
self.logger.warning(f"Last winetricks error: {last_error_details}")
self.logger.warning("=" * 80)
# Attempt fallback to protontricks
fallback_success = self._install_components_protontricks_only(components_to_install, wineprefix, game_var)
if fallback_success:
self.logger.info("SUCCESS: Protontricks fallback succeeded where winetricks failed")
return True
else:
self.logger.error("FAILURE: Both winetricks and protontricks fallback failed")
return False
else:
self.logger.error("Protontricks not available for fallback")
self.logger.error(f"Final winetricks error details: {last_error_details}")
return False
except Exception as e:
self.logger.error(f"Could not check for protontricks fallback: {e}")
return False
self.logger.error(f"Failed to install Wine components after {max_attempts} attempts.")
return False
def _reorder_components_for_installation(self, components: list) -> list:

View File

@@ -3020,7 +3020,7 @@ echo Prefix creation complete.
'/v', 'mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
]
result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True)
result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True, errors='replace')
if result1.returncode == 0:
logger.info("Successfully applied mscoree=native DLL override")
else:
@@ -3035,7 +3035,7 @@ echo Prefix creation complete.
'/v', 'OnlyUseLatestCLR', '/t', 'REG_DWORD', '/d', '1', '/f'
]
result2 = subprocess.run(cmd2, env=env, capture_output=True, text=True)
result2 = subprocess.run(cmd2, env=env, capture_output=True, text=True, errors='replace')
if result2.returncode == 0:
logger.info("Successfully applied OnlyUseLatestCLR=1 registry entry")
else:

View File

@@ -451,10 +451,8 @@ class JackifyCLI:
elif choice == "wabbajack":
self.menus['wabbajack'].show_wabbajack_tasks_menu(self)
# HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY
# elif choice == "hoolamike":
# self.menus['hoolamike'].show_hoolamike_menu(self)
# elif choice == "additional":
# self.menus['additional'].show_additional_tasks_menu(self)
elif choice == "additional":
self.menus['additional'].show_additional_tasks_menu(self)
else:
logger.warning(f"Invalid choice '{choice}' received from show_main_menu.")

View File

@@ -6,7 +6,7 @@ Extracted from src.modules.menu_handler.MenuHandler.show_additional_tasks_menu()
import time
from jackify.shared.colors import (
COLOR_SELECTION, COLOR_RESET, COLOR_ACTION, COLOR_PROMPT, COLOR_INFO, COLOR_DISABLED
COLOR_SELECTION, COLOR_RESET, COLOR_ACTION, COLOR_PROMPT, COLOR_INFO, COLOR_DISABLED, COLOR_WARNING
)
from jackify.shared.ui_utils import print_jackify_banner, print_section_header, clear_screen
@@ -24,29 +24,26 @@ class AdditionalMenuHandler:
clear_screen()
def show_additional_tasks_menu(self, cli_instance):
"""Show the MO2, NXM Handling & Recovery submenu"""
"""Show the Additional Tasks & Tools submenu"""
while True:
self._clear_screen()
print_jackify_banner()
print_section_header("Additional Utilities") # Broader title
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Install Mod Organizer 2 (Base Setup)")
print(f" {COLOR_ACTION}→ Proton setup for a standalone MO2 instance{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure NXM Handling {COLOR_DISABLED}(Not Implemented){COLOR_RESET}")
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Jackify Recovery Tools")
print(f" {COLOR_ACTION}→ Restore files modified or backed up by Jackify{COLOR_RESET}")
print_section_header("Additional Tasks & Tools")
print(f"{COLOR_INFO}Additional Tasks & Tools, such as TTW Installation{COLOR_RESET}\n")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Tale of Two Wastelands (TTW) Installation")
print(f" {COLOR_ACTION}→ Install TTW using Hoolamike native automation{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Coming Soon...")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-3): {COLOR_RESET}").strip()
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
if selection.lower() == 'q': # Allow 'q' to re-display menu
continue
if selection == "1":
self._execute_legacy_install_mo2(cli_instance)
self._execute_hoolamike_ttw_install(cli_instance)
elif selection == "2":
print(f"{COLOR_INFO}Configure NXM Handling is not yet implemented.{COLOR_RESET}")
input("\nPress Enter to return to the Utilities menu...")
elif selection == "3":
self._execute_legacy_recovery_menu(cli_instance)
print(f"\n{COLOR_INFO}More features coming soon!{COLOR_RESET}")
input("\nPress Enter to return to menu...")
elif selection == "0":
break
else:
@@ -69,4 +66,59 @@ class AdditionalMenuHandler:
recovery_handler = RecoveryMenuHandler()
recovery_handler.logger = self.logger
recovery_handler.show_recovery_menu(cli_instance)
recovery_handler.show_recovery_menu(cli_instance)
def _execute_hoolamike_ttw_install(self, cli_instance):
"""Execute TTW installation using Hoolamike handler"""
from ....backend.handlers.hoolamike_handler import HoolamikeHandler
from ....backend.models.configuration import SystemInfo
from ....shared.colors import COLOR_ERROR
system_info = SystemInfo(is_steamdeck=cli_instance.system_info.is_steamdeck)
hoolamike_handler = HoolamikeHandler(
steamdeck=system_info.is_steamdeck,
verbose=cli_instance.verbose,
filesystem_handler=cli_instance.filesystem_handler,
config_handler=cli_instance.config_handler,
menu_handler=cli_instance.menu_handler
)
# First check if Hoolamike is installed
if not hoolamike_handler.hoolamike_installed:
print(f"\n{COLOR_WARNING}Hoolamike is not installed. Installing Hoolamike first...{COLOR_RESET}")
if not hoolamike_handler.install_update_hoolamike():
print(f"{COLOR_ERROR}Failed to install Hoolamike. Cannot proceed with TTW installation.{COLOR_RESET}")
input("Press Enter to return to menu...")
return
# Run TTW installation workflow
print(f"\n{COLOR_INFO}Starting TTW installation workflow...{COLOR_RESET}")
result = hoolamike_handler.install_ttw()
if result is None:
print(f"\n{COLOR_WARNING}TTW installation returned without result.{COLOR_RESET}")
input("Press Enter to return to menu...")
def _execute_hoolamike_modlist_install(self, cli_instance):
"""Execute modlist installation using Hoolamike handler"""
from ....backend.handlers.hoolamike_handler import HoolamikeHandler
from ....backend.models.configuration import SystemInfo
system_info = SystemInfo(is_steamdeck=cli_instance.system_info.is_steamdeck)
hoolamike_handler = HoolamikeHandler(
steamdeck=system_info.is_steamdeck,
verbose=cli_instance.verbose,
filesystem_handler=cli_instance.filesystem_handler,
config_handler=cli_instance.config_handler,
menu_handler=cli_instance.menu_handler
)
# First check if Hoolamike is installed
if not hoolamike_handler.hoolamike_installed:
print(f"\n{COLOR_WARNING}Hoolamike is not installed. Installing Hoolamike first...{COLOR_RESET}")
if not hoolamike_handler.install_update_hoolamike():
print(f"{COLOR_ERROR}Failed to install Hoolamike. Cannot proceed with modlist installation.{COLOR_RESET}")
input("Press Enter to return to menu...")
return
# Run modlist installation
hoolamike_handler.install_modlist()

View File

@@ -42,36 +42,17 @@ class MainMenuHandler:
print(f"{COLOR_SELECTION}{'-'*22}{COLOR_RESET}") # Standard separator
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Modlist Tasks")
print(f" {COLOR_ACTION}→ Install & Configure Modlists{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Coming Soon...")
print(f" {COLOR_ACTION}More features coming in future releases{COLOR_RESET}")
if self.dev_mode:
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Hoolamike Tasks")
print(f" {COLOR_ACTION}→ Wabbajack alternative: Install Modlists, TTW, etc{COLOR_RESET}")
print(f"{COLOR_SELECTION}4.{COLOR_RESET} Additional Tasks")
print(f" {COLOR_ACTION}→ Install Wabbajack (via WINE), MO2, NXM Handling, Jackify Recovery{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Additional Tasks & Tools")
print(f" {COLOR_ACTION}TTW automation, Wabbajack via Wine, MO2, NXM Handling, Recovery{COLOR_RESET}")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Exit Jackify")
if self.dev_mode:
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-4): {COLOR_RESET}").strip()
else:
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
if choice.lower() == 'q': # Allow 'q' to re-display menu
continue
if choice == "1":
return "wabbajack"
elif choice == "2":
# Additional features are coming in future releases
print(f"\n{COLOR_PROMPT}Coming Soon!{COLOR_RESET}")
print(f"More features will be added in future releases.")
print(f"Please use 'Modlist Tasks' for all current functionality.")
print(f"Press Enter to continue...")
input()
continue # Return to main menu
if self.dev_mode:
if choice == "3":
return "hoolamike"
elif choice == "4":
return "additional"
return "additional"
elif choice == "0":
return "exit"
else:

View File

@@ -909,18 +909,24 @@ class JackifyMainWindow(QMainWindow):
# Create screens using refactored codebase
from jackify.frontends.gui.screens import (
MainMenu, ModlistTasksScreen,
MainMenu, ModlistTasksScreen, AdditionalTasksScreen,
InstallModlistScreen, ConfigureNewModlistScreen, ConfigureExistingModlistScreen
)
from jackify.frontends.gui.screens.install_ttw import InstallTTWScreen
self.main_menu = MainMenu(stacked_widget=self.stacked_widget, dev_mode=dev_mode)
self.feature_placeholder = FeaturePlaceholder(stacked_widget=self.stacked_widget)
self.modlist_tasks_screen = ModlistTasksScreen(
stacked_widget=self.stacked_widget,
stacked_widget=self.stacked_widget,
main_menu_index=0,
dev_mode=dev_mode
)
self.additional_tasks_screen = AdditionalTasksScreen(
stacked_widget=self.stacked_widget,
main_menu_index=0,
system_info=self.system_info
)
self.install_modlist_screen = InstallModlistScreen(
stacked_widget=self.stacked_widget,
main_menu_index=0
@@ -933,14 +939,26 @@ class JackifyMainWindow(QMainWindow):
stacked_widget=self.stacked_widget,
main_menu_index=0
)
self.install_ttw_screen = InstallTTWScreen(
stacked_widget=self.stacked_widget,
main_menu_index=0,
system_info=self.system_info
)
# Let TTW screen request window resize for expand/collapse
try:
self.install_ttw_screen.resize_request.connect(self._on_child_resize_request)
except Exception:
pass
# Add screens to stacked widget
self.stacked_widget.addWidget(self.main_menu) # Index 0: Main Menu
self.stacked_widget.addWidget(self.feature_placeholder) # Index 1: Placeholder
self.stacked_widget.addWidget(self.modlist_tasks_screen) # Index 2: Modlist Tasks
self.stacked_widget.addWidget(self.install_modlist_screen) # Index 3: Install Modlist
self.stacked_widget.addWidget(self.configure_new_modlist_screen) # Index 4: Configure New
self.stacked_widget.addWidget(self.configure_existing_modlist_screen) # Index 5: Configure Existing
self.stacked_widget.addWidget(self.additional_tasks_screen) # Index 3: Additional Tasks
self.stacked_widget.addWidget(self.install_modlist_screen) # Index 4: Install Modlist
self.stacked_widget.addWidget(self.install_ttw_screen) # Index 5: Install TTW
self.stacked_widget.addWidget(self.configure_new_modlist_screen) # Index 6: Configure New
self.stacked_widget.addWidget(self.configure_existing_modlist_screen) # Index 7: Configure Existing
# Add debug tracking for screen changes
self.stacked_widget.currentChanged.connect(self._debug_screen_change)
@@ -1025,9 +1043,11 @@ class JackifyMainWindow(QMainWindow):
0: "Main Menu",
1: "Feature Placeholder",
2: "Modlist Tasks Menu",
3: "Install Modlist Screen",
4: "Configure New Modlist",
5: "Configure Existing Modlist"
3: "Additional Tasks Menu",
4: "Install Modlist Screen",
5: "Install TTW Screen",
6: "Configure New Modlist",
7: "Configure Existing Modlist"
}
screen_name = screen_names.get(index, f"Unknown Screen (Index {index})")
widget = self.stacked_widget.widget(index)
@@ -1180,6 +1200,80 @@ class JackifyMainWindow(QMainWindow):
import traceback
traceback.print_exc()
def _on_child_resize_request(self, mode: str):
debug_print(f"DEBUG: _on_child_resize_request called with mode='{mode}', current_size={self.size()}")
# On Steam Deck we keep the stable, full-size layout and ignore child resize
try:
if self.system_info and self.system_info.is_steamdeck:
debug_print("DEBUG: Steam Deck detected, ignoring resize request")
# Hide the checkbox if present (Deck uses full layout)
try:
if hasattr(self, 'install_ttw_screen') and self.install_ttw_screen.show_details_checkbox:
self.install_ttw_screen.show_details_checkbox.setVisible(False)
except Exception:
pass
return
except Exception:
pass
# Ensure we can actually resize
self.showNormal()
self.setMaximumHeight(16777215)
debug_print(f"DEBUG: Set max height to unlimited, current_size={self.size()}")
if mode == 'expand':
# Restore a sensible minimum and expand height
min_width = max(1200, self.minimumWidth())
min_height = 900
debug_print(f"DEBUG: Expand mode - min_width={min_width}, min_height={min_height}")
try:
from PySide6.QtCore import QSize
self.setMinimumSize(QSize(min_width, min_height))
except Exception:
self.setMinimumSize(min_width, min_height)
# Animate to target height
target_height = max(self.size().height(), min_height)
self._animate_height(target_height)
else:
# Collapse to compact height computed from the TTW screen's sizeHint
try:
content_hint = self.install_ttw_screen.sizeHint().height()
except Exception:
content_hint = 460
compact_height = max(440, min(560, content_hint + 20))
debug_print(f"DEBUG: Collapse mode - content_hint={content_hint}, compact_height={compact_height}")
from PySide6.QtCore import QSize
self.setMaximumHeight(compact_height)
self.setMinimumSize(QSize(max(1200, self.minimumWidth()), compact_height))
# Animate to compact height
self._animate_height(compact_height)
def _animate_height(self, target_height: int, duration_ms: int = 180):
"""Smoothly animate the window height to target_height.
Kept local imports to minimize global impact and avoid touching module headers.
"""
try:
from PySide6.QtCore import QEasingCurve, QPropertyAnimation, QRect
except Exception:
# Fallback to immediate resize if animation types are unavailable
before = self.size()
self.resize(self.size().width(), target_height)
debug_print(f"DEBUG: Animated fallback resize from {before} to {self.size()}")
return
# Build end rect with same x/y/width and target height
start_rect = self.geometry()
end_rect = QRect(start_rect.x(), start_rect.y(), start_rect.width(), target_height)
# Hold reference to avoid GC stopping the animation
self._resize_anim = QPropertyAnimation(self, b"geometry")
self._resize_anim.setDuration(duration_ms)
self._resize_anim.setEasingCurve(QEasingCurve.OutCubic)
self._resize_anim.setStartValue(start_rect)
self._resize_anim.setEndValue(end_rect)
self._resize_anim.start()
def resource_path(relative_path):
if hasattr(sys, '_MEIPASS'):

View File

@@ -6,6 +6,7 @@ Contains all the GUI screen components for Jackify.
from .main_menu import MainMenu
from .modlist_tasks import ModlistTasksScreen
from .additional_tasks import AdditionalTasksScreen
from .install_modlist import InstallModlistScreen
from .configure_new_modlist import ConfigureNewModlistScreen
from .configure_existing_modlist import ConfigureExistingModlistScreen
@@ -13,6 +14,7 @@ from .configure_existing_modlist import ConfigureExistingModlistScreen
__all__ = [
'MainMenu',
'ModlistTasksScreen',
'AdditionalTasksScreen',
'InstallModlistScreen',
'ConfigureNewModlistScreen',
'ConfigureExistingModlistScreen'

View File

@@ -0,0 +1,169 @@
"""
Additional Tasks & Tools Screen
Simple screen for TTW automation only.
Follows the same pattern as ModlistTasksScreen.
"""
import logging
from typing import Optional
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QGridLayout
)
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont
from jackify.backend.models.configuration import SystemInfo
from ..shared_theme import JACKIFY_COLOR_BLUE
logger = logging.getLogger(__name__)
class AdditionalTasksScreen(QWidget):
"""Simple Additional Tasks screen for TTW only"""
def __init__(self, stacked_widget=None, main_menu_index=0, system_info: Optional[SystemInfo] = None):
super().__init__()
self.stacked_widget = stacked_widget
self.main_menu_index = main_menu_index
self.system_info = system_info or SystemInfo(is_steamdeck=False)
self._setup_ui()
def _setup_ui(self):
"""Set up the user interface following ModlistTasksScreen pattern"""
layout = QVBoxLayout()
layout.setContentsMargins(40, 40, 40, 40)
layout.setSpacing(0)
# Header section
self._setup_header(layout)
# Menu buttons section
self._setup_menu_buttons(layout)
# Bottom spacer
layout.addStretch()
self.setLayout(layout)
def _setup_header(self, layout):
"""Set up the header section"""
header_layout = QVBoxLayout()
header_layout.setSpacing(0)
# Title
title = QLabel("<b>Additional Tasks & Tools</b>")
title.setStyleSheet(f"font-size: 24px; color: {JACKIFY_COLOR_BLUE};")
title.setAlignment(Qt.AlignHCenter)
header_layout.addWidget(title)
# Add a spacer to match main menu vertical spacing
header_layout.addSpacing(16)
# Description
desc = QLabel(
"TTW automation and additional tools.<br>&nbsp;"
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #ccc;")
desc.setAlignment(Qt.AlignHCenter)
header_layout.addWidget(desc)
header_layout.addSpacing(24)
# Separator (shorter like main menu)
sep = QLabel()
sep.setFixedHeight(2)
sep.setFixedWidth(400) # Match button width
sep.setStyleSheet("background: #fff;")
header_layout.addWidget(sep, alignment=Qt.AlignHCenter)
header_layout.addSpacing(16)
layout.addLayout(header_layout)
def _setup_menu_buttons(self, layout):
"""Set up the menu buttons section"""
# Menu options - ONLY TTW and placeholder
MENU_ITEMS = [
("Install TTW", "ttw_install", "Install Tale of Two Wastelands using Hoolamike automation"),
("Coming Soon...", "coming_soon", "Additional tools will be added in future updates"),
("Return to Main Menu", "return_main_menu", "Go back to the main menu"),
]
# Create grid layout for buttons (mirror ModlistTasksScreen pattern)
button_grid = QGridLayout()
button_grid.setSpacing(16)
button_grid.setAlignment(Qt.AlignHCenter)
button_width = 400
button_height = 50
for i, (label, action_id, description) in enumerate(MENU_ITEMS):
# Create button
btn = QPushButton(label)
btn.setFixedSize(button_width, button_height)
btn.setStyleSheet(f"""
QPushButton {{
background-color: #4a5568;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: bold;
text-align: center;
}}
QPushButton:hover {{
background-color: #5a6578;
}}
QPushButton:pressed {{
background-color: {JACKIFY_COLOR_BLUE};
}}
""")
btn.clicked.connect(lambda checked, a=action_id: self._handle_button_click(a))
# Description label
desc_label = QLabel(description)
desc_label.setAlignment(Qt.AlignHCenter)
desc_label.setStyleSheet("color: #999; font-size: 12px;")
desc_label.setWordWrap(True)
desc_label.setFixedWidth(button_width)
# Add to grid (button row, then description row)
button_grid.addWidget(btn, i * 2, 0, Qt.AlignHCenter)
button_grid.addWidget(desc_label, i * 2 + 1, 0, Qt.AlignHCenter)
layout.addLayout(button_grid)
# Removed _create_menu_button; using same pattern as ModlistTasksScreen
def _handle_button_click(self, action_id):
"""Handle button clicks"""
if action_id == "ttw_install":
self._show_ttw_info()
elif action_id == "coming_soon":
self._show_coming_soon_info()
elif action_id == "return_main_menu":
self._return_to_main_menu()
def _show_ttw_info(self):
"""Navigate to TTW installation screen"""
if self.stacked_widget:
# Navigate to TTW installation screen (index 5)
self.stacked_widget.setCurrentIndex(5)
def _show_coming_soon_info(self):
"""Show coming soon info"""
from ..services.message_service import MessageService
MessageService.information(
self,
"Coming Soon",
"Additional tools and features will be added in future updates.\n\n"
"Check back later for more functionality!"
)
def _return_to_main_menu(self):
"""Return to main menu"""
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.main_menu_index)

View File

@@ -412,6 +412,9 @@ class ConfigureExistingModlistScreen(QWidget):
pass
def validate_and_start_configure(self):
# Reload config to pick up any settings changes made in Settings dialog
self.config_handler.reload_config()
# Rotate log file at start of each workflow run (keep 5 backups)
from jackify.backend.handlers.logging_handler import LoggingHandler
from pathlib import Path
@@ -453,10 +456,14 @@ class ConfigureExistingModlistScreen(QWidget):
def start_workflow(self, modlist_name, install_dir, resolution):
"""Start the configuration workflow using backend service directly"""
# CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog
# This ensures Proton version and winetricks settings are current
self.config_handler._load_config()
try:
# Start time tracking
self._workflow_start_time = time.time()
self._safe_append_text("[Jackify] Starting post-install configuration...")
# Create configuration thread using backend service

View File

@@ -554,6 +554,9 @@ class ConfigureNewModlistScreen(QWidget):
return True # Continue anyway
def validate_and_start_configure(self):
# Reload config to pick up any settings changes made in Settings dialog
self.config_handler.reload_config()
# Check protontricks before proceeding
if not self._check_protontricks():
return
@@ -665,6 +668,10 @@ class ConfigureNewModlistScreen(QWidget):
MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.", safety_level="medium")
def configure_modlist(self):
# CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog
# This ensures Proton version and winetricks settings are current
self.config_handler._load_config()
install_dir = os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip()
modlist_name = self.modlist_name_edit.text().strip()
mo2_exe_path = self.install_dir_edit.text().strip()
@@ -672,12 +679,12 @@ class ConfigureNewModlistScreen(QWidget):
if not install_dir or not modlist_name:
MessageService.warning(self, "Missing Info", "Install directory or modlist name is missing.", safety_level="low")
return
# Use automated prefix service instead of manual steps
self._safe_append_text("")
self._safe_append_text("=== Steam Integration Phase ===")
self._safe_append_text("Starting automated Steam setup workflow...")
# Start automated prefix workflow
self._start_automated_prefix_workflow(modlist_name, install_dir, mo2_exe_path, resolution)

View File

@@ -396,7 +396,7 @@ class InstallModlistScreen(QWidget):
header_layout.addWidget(title)
# Description
desc = QLabel(
"This screen allows you to install a Wabbajack modlist using Jackify's native Linux tools. "
"This screen allows you to install a Wabbajack modlist using Jackify. "
"Configure your options and start the installation."
)
desc.setWordWrap(True)
@@ -1072,7 +1072,8 @@ class InstallModlistScreen(QWidget):
line_lower = line.lower()
if (
("jackify-engine" in line_lower or "7zz" in line_lower or "texconv" in line_lower or
"wine" in line_lower or "wine64" in line_lower or "protontricks" in line_lower)
"wine" in line_lower or "wine64" in line_lower or "protontricks" in line_lower or
"hoolamike" in line_lower)
and "jackify-gui.py" not in line_lower
):
cols = line.strip().split(None, 3)
@@ -1091,29 +1092,198 @@ class InstallModlistScreen(QWidget):
"""Check if protontricks is available before critical operations"""
try:
is_installed, installation_type, details = self.protontricks_service.detect_protontricks()
if not is_installed:
# Show protontricks error dialog
from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog
dialog = ProtontricksErrorDialog(self.protontricks_service, self)
result = dialog.exec()
if result == QDialog.Rejected:
return False
# Re-check after dialog
is_installed, _, _ = self.protontricks_service.detect_protontricks(use_cache=False)
return is_installed
return True
except Exception as e:
print(f"Error checking protontricks: {e}")
MessageService.warning(self, "Protontricks Check Failed",
MessageService.warning(self, "Protontricks Check Failed",
f"Unable to verify protontricks installation: {e}\n\n"
"Continuing anyway, but some features may not work correctly.")
return True # Continue anyway
def _check_ttw_eligibility(self, modlist_name: str, game_type: str, install_dir: str) -> bool:
"""Check if modlist is FNV, TTW-compatible, and doesn't already have TTW
Args:
modlist_name: Name of the installed modlist
game_type: Game type (e.g., 'falloutnv')
install_dir: Modlist installation directory
Returns:
bool: True if should offer TTW integration
"""
try:
# Check 1: Must be Fallout New Vegas
if game_type.lower() not in ['falloutnv', 'fallout new vegas', 'fallout_new_vegas']:
return False
# Check 2: Must be on whitelist
from jackify.backend.data.ttw_compatible_modlists import is_ttw_compatible
if not is_ttw_compatible(modlist_name):
return False
# Check 3: TTW must not already be installed
if self._detect_existing_ttw(install_dir):
debug_print("DEBUG: TTW already installed, skipping prompt")
return False
return True
except Exception as e:
debug_print(f"DEBUG: Error checking TTW eligibility: {e}")
return False
def _detect_existing_ttw(self, install_dir: str) -> bool:
"""Check if TTW is already installed in the modlist
Args:
install_dir: Modlist installation directory
Returns:
bool: True if TTW is already present
"""
try:
from pathlib import Path
mods_dir = Path(install_dir) / "mods"
if not mods_dir.exists():
return False
# Check for folders containing "Tale of Two Wastelands" that have actual TTW content
# Exclude separators and placeholder folders
for folder in mods_dir.iterdir():
if not folder.is_dir():
continue
folder_name_lower = folder.name.lower()
# Skip separator folders and placeholders
if "_separator" in folder_name_lower or "put" in folder_name_lower or "here" in folder_name_lower:
continue
# Check if folder name contains TTW indicator
if "tale of two wastelands" in folder_name_lower:
# Verify it has actual TTW content by checking for the main ESM
ttw_esm = folder / "TaleOfTwoWastelands.esm"
if ttw_esm.exists():
debug_print(f"DEBUG: Found existing TTW installation: {folder.name}")
return True
else:
debug_print(f"DEBUG: Found TTW folder but no ESM, skipping: {folder.name}")
return False
except Exception as e:
debug_print(f"DEBUG: Error detecting existing TTW: {e}")
return False # Assume not installed on error
def _initiate_ttw_workflow(self, modlist_name: str, install_dir: str):
"""Navigate to TTW screen and set it up for modlist integration
Args:
modlist_name: Name of the modlist that needs TTW integration
install_dir: Path to the modlist installation directory
"""
try:
# Store modlist context for later use when TTW completes
self._ttw_modlist_name = modlist_name
self._ttw_install_dir = install_dir
# Get reference to TTW screen BEFORE navigation
if self.stacked_widget:
ttw_screen = self.stacked_widget.widget(5)
# Set integration mode BEFORE navigating to avoid showEvent race condition
if hasattr(ttw_screen, 'set_modlist_integration_mode'):
ttw_screen.set_modlist_integration_mode(modlist_name, install_dir)
# Connect to completion signal to show success dialog after TTW
if hasattr(ttw_screen, 'integration_complete'):
ttw_screen.integration_complete.connect(self._on_ttw_integration_complete)
else:
debug_print("WARNING: TTW screen does not support modlist integration mode yet")
# Navigate to TTW screen AFTER setting integration mode
self.stacked_widget.setCurrentIndex(5)
# Force collapsed state shortly after navigation to avoid any
# showEvent/layout timing races that may leave it expanded
try:
from PySide6.QtCore import QTimer
QTimer.singleShot(50, lambda: getattr(ttw_screen, 'force_collapsed_state', lambda: None)())
except Exception:
pass
except Exception as e:
debug_print(f"ERROR: Failed to initiate TTW workflow: {e}")
MessageService.critical(
self,
"TTW Navigation Failed",
f"Failed to navigate to TTW installation screen: {str(e)}"
)
def _on_ttw_integration_complete(self, success: bool, ttw_version: str = ""):
"""Handle completion of TTW integration and show final success dialog
Args:
success: Whether TTW integration completed successfully
ttw_version: Version of TTW that was installed
"""
try:
if not success:
MessageService.critical(
self,
"TTW Integration Failed",
"Tale of Two Wastelands integration did not complete successfully."
)
return
# Navigate back to this screen to show success dialog
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(4)
# Build success message including TTW installation
modlist_name = getattr(self, '_ttw_modlist_name', 'Unknown')
time_str = getattr(self, '_elapsed_time_str', '0m 0s')
game_name = "Fallout New Vegas"
# Show enhanced success dialog
success_dialog = SuccessDialog(
modlist_name=modlist_name,
workflow_type="install",
time_taken=time_str,
game_name=game_name,
parent=self
)
# Add TTW installation info to dialog if possible
if hasattr(success_dialog, 'add_info_line'):
success_dialog.add_info_line(f"TTW {ttw_version} integrated successfully")
success_dialog.show()
except Exception as e:
debug_print(f"ERROR: Failed to show final success dialog: {e}")
MessageService.critical(
self,
"Display Error",
f"TTW integration completed but failed to show success dialog: {str(e)}"
)
def _on_api_key_save_toggled(self, checked):
"""Handle immediate API key saving with silent validation when checkbox is toggled"""
try:
@@ -1188,11 +1358,14 @@ class InstallModlistScreen(QWidget):
import time
self._install_workflow_start_time = time.time()
debug_print('DEBUG: validate_and_start_install called')
# Reload config to pick up any settings changes made in Settings dialog
self.config_handler.reload_config()
# Check protontricks before proceeding
if not self._check_protontricks():
return
# Disable all controls during installation (except Cancel)
self._disable_controls_during_operation()
@@ -1764,6 +1937,11 @@ class InstallModlistScreen(QWidget):
MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.")
def start_automated_prefix_workflow(self):
"""Start the automated prefix creation workflow"""
# CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog
# This ensures Proton version and winetricks settings are current
self.config_handler._load_config()
# Ensure _current_resolution is always set before starting workflow
if not hasattr(self, '_current_resolution') or self._current_resolution is None:
resolution = self.resolution_combo.currentText() if hasattr(self, 'resolution_combo') else None
@@ -1775,7 +1953,7 @@ class InstallModlistScreen(QWidget):
self._current_resolution = resolution
else:
self._current_resolution = None
"""Start the automated prefix creation workflow"""
try:
# Disable controls during installation
self._disable_controls_during_operation()
@@ -2002,6 +2180,31 @@ class InstallModlistScreen(QWidget):
'enderal': 'Enderal'
}
game_name = display_names.get(self._current_game_type, self._current_game_name)
# Check for TTW eligibility before showing final success dialog
install_dir = self.install_dir_edit.text().strip()
if self._check_ttw_eligibility(modlist_name, self._current_game_type, install_dir):
# Offer TTW installation
reply = MessageService.question(
self,
"Install TTW?",
f"{modlist_name} requires Tale of Two Wastelands!\n\n"
"Would you like to install and configure TTW automatically now?\n\n"
"This will:\n"
"• Guide you through TTW installation\n"
"• Automatically integrate TTW into your modlist\n"
"• Configure load order correctly\n\n"
"Note: TTW installation can take a while. You can also install TTW later from Additional Tasks & Tools.",
critical=False,
safety_level="medium"
)
if reply == QMessageBox.Yes:
# Navigate to TTW screen
self._initiate_ttw_workflow(modlist_name, install_dir)
return # Don't show success dialog yet, will show after TTW completes
# Show normal success dialog
success_dialog = SuccessDialog(
modlist_name=modlist_name,
workflow_type="install",
@@ -2747,7 +2950,4 @@ https://wiki.scenicroute.games/Somnium/1_Installation.html</i>"""
# Re-enable controls (in case they were disabled from previous errors)
self._enable_controls_after_operation()
def closeEvent(self, event):
"""Handle window close event - clean up processes"""
self.cleanup_processes()
event.accept()

File diff suppressed because it is too large Load Diff

View File

@@ -47,12 +47,9 @@ class MainMenu(QWidget):
button_height = 60
MENU_ITEMS = [
("Modlist Tasks", "modlist_tasks", "Manage your modlists with native Linux tools"),
("Coming Soon...", "coming_soon", "More features coming soon!"),
("Additional Tasks", "additional_tasks", "Additional Tasks & Tools, such as TTW Installation"),
("Exit Jackify", "exit_jackify", "Close the application"),
]
if self.dev_mode:
MENU_ITEMS.append(("Hoolamike Tasks", "hoolamike_tasks", "Manage Hoolamike modding tools"))
MENU_ITEMS.append(("Additional Tasks", "additional_tasks", "Additional utilities and tools"))
MENU_ITEMS.append(("Exit Jackify", "exit_jackify", "Close the application"))
for label, action_id, description in MENU_ITEMS:
# Main button
@@ -121,8 +118,10 @@ class MainMenu(QWidget):
msg.exec()
elif action_id == "modlist_tasks" and self.stacked_widget:
self.stacked_widget.setCurrentIndex(2)
elif action_id == "additional_tasks" and self.stacked_widget:
self.stacked_widget.setCurrentIndex(3)
elif action_id == "return_main_menu":
# This is the main menu, so do nothing
pass
elif self.stacked_widget:
self.stacked_widget.setCurrentIndex(2) # Placeholder for now
self.stacked_widget.setCurrentIndex(1) # Default to placeholder

View File

@@ -198,11 +198,11 @@ class ModlistTasksScreen(QWidget):
if action_id == "return_main_menu":
self.stacked_widget.setCurrentIndex(0)
elif action_id == "install_modlist":
self.stacked_widget.setCurrentIndex(3)
self.stacked_widget.setCurrentIndex(4) # Install Modlist Screen
elif action_id == "configure_new_modlist":
self.stacked_widget.setCurrentIndex(4)
self.stacked_widget.setCurrentIndex(6) # Configure New Modlist Screen
elif action_id == "configure_existing_modlist":
self.stacked_widget.setCurrentIndex(5)
self.stacked_widget.setCurrentIndex(7) # Configure Existing Modlist Screen
def go_back(self):
"""Return to main menu"""

View File

@@ -220,6 +220,8 @@ class MessageService:
msg_box = MessageService._create_base_message_box(parent, critical, safety_level)
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle(title)
msg_box.setTextFormat(Qt.RichText)
msg_box.setTextInteractionFlags(Qt.TextBrowserInteraction)
msg_box.setText(message)
msg_box.setStandardButtons(buttons)
msg_box.setDefaultButton(default_button)

View File

@@ -9,6 +9,21 @@ ANSI_COLOR_MAP = {
}
ANSI_RE = re.compile(r'\x1b\[(\d+)(;\d+)?m')
# Pattern to match terminal control codes (cursor movement, line clearing, etc.)
ANSI_CONTROL_RE = re.compile(
r'\x1b\[' # CSI sequence start
r'[0-9;]*' # Parameters
r'[A-Za-z]' # Command letter
)
def strip_ansi_control_codes(text):
"""Remove ALL ANSI escape sequences including control codes.
This is useful for Hoolamike output which uses terminal control codes
for progress bars that don't render well in QTextEdit.
"""
return ANSI_CONTROL_RE.sub('', text)
def ansi_to_html(text):
"""Convert ANSI color codes to HTML"""
result = ''