diff --git a/CHANGELOG.md b/CHANGELOG.md
index 796f15e..6ccef11 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,30 @@
# Jackify Changelog
+## v0.1.7 - TTW Automation & Bug Fixes
+**Release Date:** November 1, 2025
+
+### Major Features
+- **TTW (Tale of Two Wastelands) Installation and Automation**
+ - TTW Installation function using Hoolamike application - https://github.com/Niedzwiedzw/hoolamike
+ - Automated workflow for TTW installation and integration into FNV modlists, where possible
+ - Automatic detection of TTW-compatible modlists
+ - User prompt after modlist installation with option to install TTW
+ - Automated integration: file copying, load order updates, modlist.txt updates
+ - Available in both CLI and GUI workflows
+
+### Bug Fixes
+- **Registry UTF-8 Decode Error**: Fixed crash during dotnet4.x installation when Wine outputs binary data
+- **Python 3.10 Compatibility**: Fixed startup crash on Python 3.10 systems
+- **TTW Steam Deck Layout**: Fixed window sizing issues on Steam Deck when entering/exiting TTW screen
+- **TTW Integration Status**: Added visible status banner updates during modlist integration for collapsed mode
+- **TTW Accidental Input Protection**: Added 3-second countdown to TTW installation prompt to prevent accidental dismissal
+- **Settings Persistence**: Settings changes now persist correctly across workflows
+- **Steam Deck Keyboard Input**: Fixed keyboard input failure on Steam Deck
+- **Application Close Crash**: Fixed crash when closing application on Steam Deck
+- **Winetricks Diagnostics**: Enhanced error detection with automatic fallback
+
+---
+
## v0.1.6.6 - AppImage Bundling Fix
**Release Date:** October 29, 2025
diff --git a/jackify/__init__.py b/jackify/__init__.py
index 62758fd..12979bc 100644
--- a/jackify/__init__.py
+++ b/jackify/__init__.py
@@ -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"
diff --git a/jackify/backend/data/__init__.py b/jackify/backend/data/__init__.py
new file mode 100644
index 0000000..1bb8cd5
--- /dev/null
+++ b/jackify/backend/data/__init__.py
@@ -0,0 +1,3 @@
+"""
+Data package for static configuration and reference data.
+"""
diff --git a/jackify/backend/data/ttw_compatible_modlists.py b/jackify/backend/data/ttw_compatible_modlists.py
new file mode 100644
index 0000000..76b3385
--- /dev/null
+++ b/jackify/backend/data/ttw_compatible_modlists.py
@@ -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
diff --git a/jackify/backend/handlers/config_handler.py b/jackify/backend/handlers/config_handler.py
index eeb0b0f..6e8279e 100644
--- a/jackify/backend/handlers/config_handler.py
+++ b/jackify/backend/handlers/config_handler.py
@@ -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"""
diff --git a/jackify/backend/handlers/hoolamike_handler.py b/jackify/backend/handlers/hoolamike_handler.py
index 342077b..12962c3 100644
--- a/jackify/backend/handlers/hoolamike_handler.py
+++ b/jackify/backend/handlers/hoolamike_handler.py
@@ -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')
diff --git a/jackify/backend/handlers/modlist_handler.py b/jackify/backend/handlers/modlist_handler.py
index 158c283..1b82f83 100644
--- a/jackify/backend/handlers/modlist_handler.py
+++ b/jackify/backend/handlers/modlist_handler.py
@@ -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:
diff --git a/jackify/backend/handlers/modlist_install_cli.py b/jackify/backend/handlers/modlist_install_cli.py
index 8d56f19..5e947a2 100644
--- a/jackify/backend/handlers/modlist_install_cli.py
+++ b/jackify/backend/handlers/modlist_install_cli.py
@@ -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
\ No newline at end of file
+
+ 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}")
\ No newline at end of file
diff --git a/jackify/backend/handlers/path_handler.py b/jackify/backend/handlers/path_handler.py
index 16ea53f..639b215 100644
--- a/jackify/backend/handlers/path_handler.py
+++ b/jackify/backend/handlers/path_handler.py
@@ -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()}")
diff --git a/jackify/backend/handlers/winetricks_handler.py b/jackify/backend/handlers/winetricks_handler.py
index 36ff58e..f2b108d 100644
--- a/jackify/backend/handlers/winetricks_handler.py
+++ b/jackify/backend/handlers/winetricks_handler.py
@@ -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:
diff --git a/jackify/backend/services/automated_prefix_service.py b/jackify/backend/services/automated_prefix_service.py
index 433af84..432fd50 100644
--- a/jackify/backend/services/automated_prefix_service.py
+++ b/jackify/backend/services/automated_prefix_service.py
@@ -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:
diff --git a/jackify/frontends/cli/main.py b/jackify/frontends/cli/main.py
index d1b33c0..337e31b 100755
--- a/jackify/frontends/cli/main.py
+++ b/jackify/frontends/cli/main.py
@@ -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.")
diff --git a/jackify/frontends/cli/menus/additional_menu.py b/jackify/frontends/cli/menus/additional_menu.py
index 990c44b..058523e 100644
--- a/jackify/frontends/cli/menus/additional_menu.py
+++ b/jackify/frontends/cli/menus/additional_menu.py
@@ -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)
\ No newline at end of file
+ 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()
\ No newline at end of file
diff --git a/jackify/frontends/cli/menus/main_menu.py b/jackify/frontends/cli/menus/main_menu.py
index 99539f0..e0c1a15 100644
--- a/jackify/frontends/cli/menus/main_menu.py
+++ b/jackify/frontends/cli/menus/main_menu.py
@@ -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:
diff --git a/jackify/frontends/gui/main.py b/jackify/frontends/gui/main.py
index 823a1e6..d42c39f 100644
--- a/jackify/frontends/gui/main.py
+++ b/jackify/frontends/gui/main.py
@@ -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'):
diff --git a/jackify/frontends/gui/screens/__init__.py b/jackify/frontends/gui/screens/__init__.py
index 08b7274..f92c675 100644
--- a/jackify/frontends/gui/screens/__init__.py
+++ b/jackify/frontends/gui/screens/__init__.py
@@ -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'
diff --git a/jackify/frontends/gui/screens/additional_tasks.py b/jackify/frontends/gui/screens/additional_tasks.py
new file mode 100644
index 0000000..32e3e52
--- /dev/null
+++ b/jackify/frontends/gui/screens/additional_tasks.py
@@ -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("Additional Tasks & Tools")
+ 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.
"
+ )
+ 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)
\ No newline at end of file
diff --git a/jackify/frontends/gui/screens/configure_existing_modlist.py b/jackify/frontends/gui/screens/configure_existing_modlist.py
index 9db5f90..11456cf 100644
--- a/jackify/frontends/gui/screens/configure_existing_modlist.py
+++ b/jackify/frontends/gui/screens/configure_existing_modlist.py
@@ -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
diff --git a/jackify/frontends/gui/screens/configure_new_modlist.py b/jackify/frontends/gui/screens/configure_new_modlist.py
index c9d88f5..ea4a8d4 100644
--- a/jackify/frontends/gui/screens/configure_new_modlist.py
+++ b/jackify/frontends/gui/screens/configure_new_modlist.py
@@ -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)
diff --git a/jackify/frontends/gui/screens/install_modlist.py b/jackify/frontends/gui/screens/install_modlist.py
index 6dbea11..67547dc 100644
--- a/jackify/frontends/gui/screens/install_modlist.py
+++ b/jackify/frontends/gui/screens/install_modlist.py
@@ -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"""
# 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()
\ No newline at end of file
+
\ No newline at end of file
diff --git a/jackify/frontends/gui/screens/install_ttw.py b/jackify/frontends/gui/screens/install_ttw.py
new file mode 100644
index 0000000..dd6404d
--- /dev/null
+++ b/jackify/frontends/gui/screens/install_ttw.py
@@ -0,0 +1,2932 @@
+
+"""
+InstallModlistScreen for Jackify GUI
+"""
+from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QMessageBox, QProgressDialog, QApplication, QCheckBox, QStyledItemDelegate, QStyle, QFrame
+from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject, QUrl
+from PySide6.QtGui import QPixmap, QTextCursor, QPainter, QFont
+from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
+from ..utils import ansi_to_html, strip_ansi_control_codes
+from ..widgets.unsupported_game_dialog import UnsupportedGameDialog
+import os
+import subprocess
+import sys
+import threading
+from jackify.backend.handlers.shortcut_handler import ShortcutHandler
+from jackify.backend.handlers.wabbajack_parser import WabbajackParser
+import traceback
+from jackify.backend.core.modlist_operations import get_jackify_engine_path
+import signal
+import re
+import time
+from jackify.backend.handlers.subprocess_utils import ProcessManager
+from jackify.backend.handlers.config_handler import ConfigHandler
+from ..dialogs import SuccessDialog
+from jackify.backend.handlers.validation_handler import ValidationHandler
+from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog
+from jackify.frontends.gui.services.message_service import MessageService
+
+def debug_print(message):
+ """Print debug message only if debug mode is enabled"""
+ from jackify.backend.handlers.config_handler import ConfigHandler
+ config_handler = ConfigHandler()
+ if config_handler.get('debug_mode', False):
+ print(message)
+
+class ModlistFetchThread(QThread):
+ result = Signal(list, str)
+ def __init__(self, game_type, log_path, mode='list-modlists'):
+ super().__init__()
+ self.game_type = game_type
+ self.log_path = log_path
+ self.mode = mode
+
+ def run(self):
+ try:
+ # Use proper backend service - NOT the misnamed CLI class
+ from jackify.backend.services.modlist_service import ModlistService
+ from jackify.backend.models.configuration import SystemInfo
+
+ # Initialize backend service
+ # Detect if we're on Steam Deck
+ is_steamdeck = False
+ try:
+ if os.path.exists('/etc/os-release'):
+ with open('/etc/os-release') as f:
+ if 'steamdeck' in f.read().lower():
+ is_steamdeck = True
+ except Exception:
+ pass
+
+ system_info = SystemInfo(is_steamdeck=is_steamdeck)
+ modlist_service = ModlistService(system_info)
+
+ # Get modlists using proper backend service
+ modlist_infos = modlist_service.list_modlists(game_type=self.game_type)
+
+ # Return full modlist objects instead of just IDs to preserve enhanced metadata
+ self.result.emit(modlist_infos, '')
+
+ except Exception as e:
+ error_msg = f"Backend service error: {str(e)}"
+ # Don't write to log file before workflow starts - just return error
+ self.result.emit([], error_msg)
+
+
+class InstallTTWScreen(QWidget):
+ steam_restart_finished = Signal(bool, str)
+ resize_request = Signal(str)
+ integration_complete = Signal(bool, str) # Signal for modlist integration completion (success, ttw_version)
+
+ def __init__(self, stacked_widget=None, main_menu_index=0, system_info=None):
+ super().__init__()
+ self.stacked_widget = stacked_widget
+ self.main_menu_index = main_menu_index
+ self.system_info = system_info
+ self.debug = DEBUG_BORDERS
+ self.online_modlists = {} # {game_type: [modlist_dict, ...]}
+ self.modlist_details = {} # {modlist_name: modlist_dict}
+
+ # Initialize log path (can be refreshed via refresh_paths method)
+ self.refresh_paths()
+
+ # Initialize services early
+ from jackify.backend.services.api_key_service import APIKeyService
+ from jackify.backend.services.resolution_service import ResolutionService
+ from jackify.backend.services.protontricks_detection_service import ProtontricksDetectionService
+ from jackify.backend.handlers.config_handler import ConfigHandler
+ self.api_key_service = APIKeyService()
+ self.resolution_service = ResolutionService()
+ self.config_handler = ConfigHandler()
+ self.protontricks_service = ProtontricksDetectionService()
+
+ # Modlist integration mode tracking
+ self._integration_mode = False
+ self._integration_modlist_name = None
+ self._integration_install_dir = None
+
+ # Somnium guidance tracking
+ self._show_somnium_guidance = False
+ self._somnium_install_dir = None
+
+ # Scroll tracking for professional auto-scroll behavior
+ self._user_manually_scrolled = False
+ self._was_at_bottom = True
+
+ # Initialize Wabbajack parser for game detection
+ self.wabbajack_parser = WabbajackParser()
+ # Remember original main window geometry/min-size to restore on expand
+ self._saved_geometry = None
+ self._saved_min_size = None
+
+ main_overall_vbox = QVBoxLayout(self)
+ main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
+ # Tighter outer margins and reduced inter-section spacing
+ main_overall_vbox.setContentsMargins(20, 12, 20, 0)
+ main_overall_vbox.setSpacing(6)
+ if self.debug:
+ self.setStyleSheet("border: 2px solid magenta;")
+
+ # --- Header (title, description) ---
+ header_layout = QVBoxLayout()
+ header_layout.setSpacing(1) # Reduce spacing between title and description
+ # Title (no logo)
+ title = QLabel("Install Tale of Two Wastelands (TTW)")
+ title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;")
+ title.setAlignment(Qt.AlignHCenter)
+ title.setMaximumHeight(30) # Force compact height
+ header_layout.addWidget(title)
+
+
+ # Description
+ desc = QLabel(
+ "This screen allows you to install Tale of Two Wastelands (TTW) using the Hoolamike tool. "
+ "Configure your options and start the installation."
+ )
+ desc.setWordWrap(True)
+ desc.setStyleSheet("color: #ccc; margin: 0px; padding: 0px; line-height: 1.2;")
+ desc.setAlignment(Qt.AlignHCenter)
+ desc.setMaximumHeight(40) # Force compact height for description
+ header_layout.addWidget(desc)
+ header_widget = QWidget()
+ header_widget.setLayout(header_layout)
+ # Keep header compact
+ header_widget.setMaximumHeight(90)
+ # Remove height constraint to allow status banner to show
+ if self.debug:
+ header_widget.setStyleSheet("border: 2px solid pink;")
+ header_widget.setToolTip("HEADER_SECTION")
+ main_overall_vbox.addWidget(header_widget)
+
+ # --- Upper section: user-configurables (left) + process monitor (right) ---
+ upper_hbox = QHBoxLayout()
+ upper_hbox.setContentsMargins(0, 0, 0, 0)
+ upper_hbox.setSpacing(16)
+ # Left: user-configurables (form and controls)
+ user_config_vbox = QVBoxLayout()
+ user_config_vbox.setAlignment(Qt.AlignTop)
+ user_config_vbox.setSpacing(4) # Reduce spacing between major form sections
+
+ # --- Instructions ---
+ instruction_text = QLabel(
+ "Tale of Two Wastelands installation requires a .mpi file you can get from: "
+ 'https://mod.pub/ttw/133/files '
+ "(requires a user account for mod.db)"
+ )
+ instruction_text.setWordWrap(True)
+ instruction_text.setStyleSheet("color: #ccc; font-size: 12px; margin: 0px; padding: 0px; line-height: 1.2;")
+ instruction_text.setOpenExternalLinks(True)
+ user_config_vbox.addWidget(instruction_text)
+
+ # --- Compact Form Grid for inputs (align with other screens) ---
+ form_grid = QGridLayout()
+ form_grid.setHorizontalSpacing(12)
+ form_grid.setVerticalSpacing(6)
+ form_grid.setContentsMargins(0, 0, 0, 0)
+
+ # Row 0: TTW .mpi File location
+ file_label = QLabel("TTW .mpi File location:")
+ self.file_edit = QLineEdit()
+ self.file_edit.setMaximumHeight(25)
+ self.file_edit.textChanged.connect(self._update_start_button_state)
+ self.file_btn = QPushButton("Browse")
+ self.file_btn.clicked.connect(self.browse_wabbajack_file)
+ file_hbox = QHBoxLayout()
+ file_hbox.addWidget(self.file_edit)
+ file_hbox.addWidget(self.file_btn)
+ form_grid.addWidget(file_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
+ form_grid.addLayout(file_hbox, 0, 1)
+
+ # Row 1: Output Directory
+ install_dir_label = QLabel("Output Directory:")
+ self.install_dir_edit = QLineEdit(self.config_handler.get_modlist_install_base_dir())
+ self.install_dir_edit.setMaximumHeight(25)
+ self.browse_install_btn = QPushButton("Browse")
+ self.browse_install_btn.clicked.connect(self.browse_install_dir)
+ install_dir_hbox = QHBoxLayout()
+ install_dir_hbox.addWidget(self.install_dir_edit)
+ install_dir_hbox.addWidget(self.browse_install_btn)
+ form_grid.addWidget(install_dir_label, 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
+ form_grid.addLayout(install_dir_hbox, 1, 1)
+
+ # --- Hoolamike Status aligned in form grid (row 2) ---
+ hoolamike_label = QLabel("Hoolamike Status:")
+ self.hoolamike_status = QLabel("Checking...")
+ self.hoolamike_btn = QPushButton("Install now")
+ self.hoolamike_btn.setStyleSheet("""
+ QPushButton:hover { opacity: 0.95; }
+ QPushButton:disabled { opacity: 0.6; }
+ """)
+ self.hoolamike_btn.setVisible(False)
+ self.hoolamike_btn.clicked.connect(self.install_hoolamike)
+ hoolamike_hbox = QHBoxLayout()
+ hoolamike_hbox.setContentsMargins(0, 0, 0, 0)
+ hoolamike_hbox.setSpacing(8)
+ hoolamike_hbox.addWidget(self.hoolamike_status)
+ hoolamike_hbox.addWidget(self.hoolamike_btn)
+ hoolamike_hbox.addStretch()
+ form_grid.addWidget(hoolamike_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
+ form_grid.addLayout(hoolamike_hbox, 2, 1)
+
+ # --- Game Requirements aligned in form grid (row 3) ---
+ game_req_label = QLabel("Game Requirements:")
+ self.fallout3_status = QLabel("Fallout 3: Checking...")
+ self.fallout3_status.setStyleSheet("color: #ccc;")
+ self.fnv_status = QLabel("Fallout New Vegas: Checking...")
+ self.fnv_status.setStyleSheet("color: #ccc;")
+ game_req_hbox = QHBoxLayout()
+ game_req_hbox.setContentsMargins(0, 0, 0, 0)
+ game_req_hbox.setSpacing(16)
+ game_req_hbox.addWidget(self.fallout3_status)
+ game_req_hbox.addWidget(self.fnv_status)
+ game_req_hbox.addStretch()
+ form_grid.addWidget(game_req_label, 3, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
+ form_grid.addLayout(game_req_hbox, 3, 1)
+
+ form_group = QWidget()
+ form_group.setLayout(form_grid)
+ user_config_vbox.addWidget(form_group)
+
+ # (Hoolamike and Game Requirements now aligned in form_grid above)
+
+ # --- Buttons ---
+ btn_row = QHBoxLayout()
+ btn_row.setAlignment(Qt.AlignHCenter)
+ self.start_btn = QPushButton("Start Installation")
+ self.start_btn.setEnabled(False) # Disabled until requirements are met
+ btn_row.addWidget(self.start_btn)
+
+
+
+ # Cancel button (goes back to menu)
+ self.cancel_btn = QPushButton("Cancel")
+ self.cancel_btn.clicked.connect(self.cancel_and_cleanup)
+ btn_row.addWidget(self.cancel_btn)
+
+ # Cancel Installation button (appears during installation)
+ self.cancel_install_btn = QPushButton("Cancel Installation")
+ self.cancel_install_btn.clicked.connect(self.cancel_installation)
+ self.cancel_install_btn.setVisible(False) # Hidden by default
+ btn_row.addWidget(self.cancel_install_btn)
+
+ # Add stretches to center buttons row
+ btn_row.insertStretch(0, 1)
+ btn_row.addStretch(1)
+
+ # Show Details Checkbox (collapsible console)
+ self.show_details_checkbox = QCheckBox("Show details")
+ # Start collapsed by default (console hidden until user opts in)
+ self.show_details_checkbox.setChecked(False)
+ # Use toggled(bool) for reliable signal and map to our handler
+ try:
+ self.show_details_checkbox.toggled.connect(self._on_show_details_toggled)
+ except Exception:
+ # Fallback to stateChanged if toggled is unavailable
+ self.show_details_checkbox.stateChanged.connect(self._toggle_console_visibility)
+ # Note: Checkbox will be placed in the status banner row (right-aligned)
+
+ # Wrap button row in widget for debug borders
+ btn_row_widget = QWidget()
+ btn_row_widget.setLayout(btn_row)
+ btn_row_widget.setMaximumHeight(50) # Limit height to make it more compact
+ if self.debug:
+ btn_row_widget.setStyleSheet("border: 2px solid red;")
+ btn_row_widget.setToolTip("BUTTON_ROW")
+ # Keep a reference for dynamic sizing when collapsing/expanding
+ self.btn_row_widget = btn_row_widget
+ user_config_widget = QWidget()
+ user_config_widget.setLayout(user_config_vbox)
+ user_config_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) # Allow vertical expansion to fill space
+ if self.debug:
+ user_config_widget.setStyleSheet("border: 2px solid orange;")
+ user_config_widget.setToolTip("USER_CONFIG_WIDGET")
+ # Right: process monitor (as before)
+ self.process_monitor = QTextEdit()
+ self.process_monitor.setReadOnly(True)
+ self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
+ self.process_monitor.setMinimumSize(QSize(300, 20))
+ self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;")
+ self.process_monitor_heading = QLabel("[Process Monitor]")
+ self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;")
+ self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
+ process_vbox = QVBoxLayout()
+ process_vbox.setContentsMargins(0, 0, 0, 0)
+ process_vbox.setSpacing(2)
+ process_vbox.addWidget(self.process_monitor_heading)
+ process_vbox.addWidget(self.process_monitor)
+ process_monitor_widget = QWidget()
+ process_monitor_widget.setLayout(process_vbox)
+ if self.debug:
+ process_monitor_widget.setStyleSheet("border: 2px solid purple;")
+ process_monitor_widget.setToolTip("PROCESS_MONITOR")
+ upper_hbox.addWidget(user_config_widget, stretch=1)
+ upper_hbox.addWidget(process_monitor_widget, stretch=3)
+ upper_hbox.setAlignment(Qt.AlignTop)
+ self.upper_section_widget = QWidget()
+ self.upper_section_widget.setLayout(upper_hbox)
+ # Keep the top section tightly wrapped to its content height
+ try:
+ self.upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
+ self.upper_section_widget.setMaximumHeight(self.upper_section_widget.sizeHint().height())
+ except Exception:
+ pass
+ if self.debug:
+ self.upper_section_widget.setStyleSheet("border: 2px solid green;")
+ self.upper_section_widget.setToolTip("UPPER_SECTION")
+ main_overall_vbox.addWidget(self.upper_section_widget)
+
+ # --- Status Banner (shows high-level progress) ---
+ self.status_banner = QLabel("Ready to install")
+ self.status_banner.setAlignment(Qt.AlignCenter)
+ self.status_banner.setStyleSheet(f"""
+ background-color: #2a2a2a;
+ color: {JACKIFY_COLOR_BLUE};
+ padding: 6px 8px;
+ border-radius: 4px;
+ font-weight: bold;
+ font-size: 13px;
+ """)
+ # Prevent banner from expanding vertically
+ self.status_banner.setMaximumHeight(34)
+ self.status_banner.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
+ # Show the banner by default so users see status even when collapsed
+ self.status_banner.setVisible(True)
+ # Create a compact banner row with the checkbox right-aligned
+ banner_row = QHBoxLayout()
+ # Minimal padding to avoid visible gaps
+ banner_row.setContentsMargins(0, 0, 0, 0)
+ banner_row.setSpacing(8)
+ banner_row.addWidget(self.status_banner, 1)
+ banner_row.addStretch()
+ banner_row.addWidget(self.show_details_checkbox)
+ banner_row_widget = QWidget()
+ banner_row_widget.setLayout(banner_row)
+ main_overall_vbox.addWidget(banner_row_widget)
+
+ # Remove spacing - console should expand to fill available space
+ # --- Console output area (full width, placeholder for now) ---
+ self.console = QTextEdit()
+ self.console.setReadOnly(True)
+ self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
+ # Console starts hidden; toggled via Show details
+ self.console.setMinimumHeight(0)
+ self.console.setMaximumHeight(0)
+ self.console.setFontFamily('monospace')
+ if self.debug:
+ self.console.setStyleSheet("border: 2px solid yellow;")
+ self.console.setToolTip("CONSOLE")
+
+ # Set up scroll tracking for professional auto-scroll behavior
+ self._setup_scroll_tracking()
+
+ # Add console directly so we can hide/show without affecting buttons
+ main_overall_vbox.addWidget(self.console, stretch=1)
+ # Place the button row after the console so it's always visible and centered
+ main_overall_vbox.addWidget(btn_row_widget, alignment=Qt.AlignHCenter)
+
+ # Store reference to main layout
+ self.main_overall_vbox = main_overall_vbox
+ self.setLayout(main_overall_vbox)
+
+ self.current_modlists = []
+
+ # --- Process Monitor (right) ---
+ self.process = None
+ self.log_timer = None
+ self.last_log_pos = 0
+ # --- Process Monitor Timer ---
+ self.top_timer = QTimer(self)
+ self.top_timer.timeout.connect(self.update_top_panel)
+ self.top_timer.start(2000)
+ # --- Start Installation button ---
+ self.start_btn.clicked.connect(self.validate_and_start_install)
+ self.steam_restart_finished.connect(self._on_steam_restart_finished)
+
+ # Initialize process tracking
+ self.process = None
+
+ # Initialize empty controls list - will be populated after UI is built
+ self._actionable_controls = []
+
+ def check_requirements(self):
+ """Check and display requirements status"""
+ from jackify.backend.handlers.path_handler import PathHandler
+ from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler
+ from jackify.backend.handlers.filesystem_handler import FileSystemHandler
+ from jackify.backend.handlers.config_handler import ConfigHandler
+ from jackify.backend.models.configuration import SystemInfo
+
+ path_handler = PathHandler()
+
+ # Check game detection
+ detected_games = path_handler.find_vanilla_game_paths()
+
+ # Fallout 3
+ if 'Fallout 3' in detected_games:
+ self.fallout3_status.setText("Fallout 3: Detected")
+ self.fallout3_status.setStyleSheet("color: #3fd0ea;")
+ else:
+ self.fallout3_status.setText("Fallout 3: Not Found - Install from Steam")
+ self.fallout3_status.setStyleSheet("color: #f44336;")
+
+ # Fallout New Vegas
+ if 'Fallout New Vegas' in detected_games:
+ self.fnv_status.setText("Fallout New Vegas: Detected")
+ self.fnv_status.setStyleSheet("color: #3fd0ea;")
+ else:
+ self.fnv_status.setText("Fallout New Vegas: Not Found - Install from Steam")
+ self.fnv_status.setStyleSheet("color: #f44336;")
+
+ # Update Start button state after checking requirements
+ self._update_start_button_state()
+
+ def _check_hoolamike_status(self):
+ """Check Hoolamike installation status and update UI"""
+ try:
+ from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler
+ from jackify.backend.handlers.filesystem_handler import FileSystemHandler
+ from jackify.backend.handlers.config_handler import ConfigHandler
+ from jackify.backend.models.configuration import SystemInfo
+
+ # Create handler instances
+ filesystem_handler = FileSystemHandler()
+ config_handler = ConfigHandler()
+ system_info = SystemInfo(is_steamdeck=False)
+ hoolamike_handler = HoolamikeHandler(
+ steamdeck=False,
+ verbose=False,
+ filesystem_handler=filesystem_handler,
+ config_handler=config_handler
+ )
+
+ # Check if Hoolamike is installed
+ hoolamike_handler._check_hoolamike_installation()
+
+ if hoolamike_handler.hoolamike_installed:
+ # Check version against latest
+ update_available, installed_v, latest_v = hoolamike_handler.is_hoolamike_update_available()
+ if update_available:
+ self.hoolamike_status.setText("Out of date")
+ self.hoolamike_status.setStyleSheet("color: #f44336;")
+ self.hoolamike_btn.setText("Update now")
+ self.hoolamike_btn.setEnabled(True)
+ self.hoolamike_btn.setVisible(True)
+ else:
+ self.hoolamike_status.setText("Ready")
+ self.hoolamike_status.setStyleSheet("color: #3fd0ea;")
+ self.hoolamike_btn.setText("Update now")
+ self.hoolamike_btn.setEnabled(False) # Greyed out when ready
+ self.hoolamike_btn.setVisible(True)
+ else:
+ self.hoolamike_status.setText("Not Found")
+ self.hoolamike_status.setStyleSheet("color: #f44336;")
+ self.hoolamike_btn.setText("Install now")
+ self.hoolamike_btn.setEnabled(True)
+ self.hoolamike_btn.setVisible(True)
+
+ except Exception as e:
+ self.hoolamike_status.setText("Check Failed")
+ self.hoolamike_status.setStyleSheet("color: #f44336;")
+ self.hoolamike_btn.setText("Install now")
+ self.hoolamike_btn.setEnabled(True)
+ self.hoolamike_btn.setVisible(True)
+ debug_print(f"DEBUG: Hoolamike status check failed: {e}")
+
+ def install_hoolamike(self):
+ """Install or update Hoolamike"""
+ # If not detected, show an appreciation/info dialog about Hoolamike first
+ try:
+ current_status = self.hoolamike_status.text().strip()
+ except Exception:
+ current_status = ""
+ if current_status == "Not Found":
+ MessageService.information(
+ self,
+ "Hoolamike Installation",
+ (
+ "Hoolamike is a community-made installer that enables the installation of modlists and TTW on Linux.
"
+ "Project: github.com/Niedzwiedzw/hoolamike
"
+ "Please star the repository and thank the developer.
"
+ "Jackify will now download and install the latest Linux build of Hoolamike."
+ ),
+ safety_level="low",
+ )
+
+ # Update button to show installation in progress
+ self.hoolamike_btn.setText("Installing...")
+ self.hoolamike_btn.setEnabled(False)
+
+ self.console.append("Installing/updating Hoolamike...")
+
+ try:
+ from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler
+ from jackify.backend.handlers.filesystem_handler import FileSystemHandler
+ from jackify.backend.handlers.config_handler import ConfigHandler
+ from jackify.backend.models.configuration import SystemInfo
+
+ # Create handler instances
+ filesystem_handler = FileSystemHandler()
+ config_handler = ConfigHandler()
+ system_info = SystemInfo(is_steamdeck=False)
+ hoolamike_handler = HoolamikeHandler(
+ steamdeck=False,
+ verbose=False,
+ filesystem_handler=filesystem_handler,
+ config_handler=config_handler
+ )
+
+ # Install Hoolamike
+ success, message = hoolamike_handler.install_hoolamike()
+
+ if success:
+ # Extract path from message if available, or show config path
+ install_path = hoolamike_handler.hoolamike_app_install_path
+ self.console.append("Hoolamike installed successfully")
+ self.console.append(f"Installation location: {install_path}")
+ self.console.append("Re-checking Hoolamike status...")
+ # Re-check Hoolamike status after installation
+ self._check_hoolamike_status()
+ self._update_start_button_state()
+
+ # Update button to show successful installation
+ self.hoolamike_btn.setText("Installed")
+ # Keep button disabled - no need to reinstall
+ else:
+ self.console.append(f"Installation failed: {message}")
+ # Re-enable button on failure so user can retry
+ self.hoolamike_btn.setText("Install now")
+ self.hoolamike_btn.setEnabled(True)
+
+ except Exception as e:
+ self.console.append(f"Error installing Hoolamike: {str(e)}")
+ debug_print(f"DEBUG: Hoolamike installation error: {e}")
+ # Re-enable button on exception so user can retry
+ self.hoolamike_btn.setText("Install now")
+ self.hoolamike_btn.setEnabled(True)
+
+ def _check_ttw_requirements(self):
+ """Check TTW requirements before installation"""
+ from jackify.backend.handlers.path_handler import PathHandler
+ from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler
+ from jackify.backend.handlers.filesystem_handler import FileSystemHandler
+ from jackify.backend.handlers.config_handler import ConfigHandler
+
+ path_handler = PathHandler()
+
+ # Check game detection
+ detected_games = path_handler.find_vanilla_game_paths()
+ missing_games = []
+
+ if 'Fallout 3' not in detected_games:
+ missing_games.append("Fallout 3")
+ if 'Fallout New Vegas' not in detected_games:
+ missing_games.append("Fallout New Vegas")
+
+ if missing_games:
+ MessageService.warning(
+ self,
+ "Missing Required Games",
+ f"TTW requires both Fallout 3 and Fallout New Vegas to be installed.\n\nMissing: {', '.join(missing_games)}"
+ )
+ return False
+
+ # Check Hoolamike using the status we already checked
+ status_text = self.hoolamike_status.text()
+ if status_text in ("Not Found", "Check Failed"):
+ MessageService.warning(
+ self,
+ "Hoolamike Required",
+ "Hoolamike is required for TTW installation but is not installed.\n\nPlease install Hoolamike using the 'Install now' button."
+ )
+ return False
+
+ return True
+
+ # Now collect all actionable controls after UI is fully built
+ self._collect_actionable_controls()
+
+ # Check if all requirements are met and enable/disable Start button
+ self._update_start_button_state()
+
+ def _update_start_button_state(self):
+ """Enable/disable Start button based on requirements and file selection"""
+ # Check if all requirements are met
+ requirements_met = self._check_ttw_requirements()
+
+ # Check if .mpi file is selected
+ mpi_file_selected = bool(self.file_edit.text().strip())
+
+ # Enable Start button only if both requirements are met and file is selected
+ self.start_btn.setEnabled(requirements_met and mpi_file_selected)
+
+ # Update button text to indicate what's missing
+ if not requirements_met:
+ self.start_btn.setText("Requirements Not Met")
+ elif not mpi_file_selected:
+ self.start_btn.setText("Select TTW .mpi File")
+ else:
+ self.start_btn.setText("Start Installation")
+
+ def _collect_actionable_controls(self):
+ """Collect all actionable controls that should be disabled during operations (except Cancel)"""
+ self._actionable_controls = [
+ # Main action button
+ self.start_btn,
+ # File selection
+ self.file_edit,
+ self.file_btn,
+ # Install directory
+ self.install_dir_edit,
+ self.browse_install_btn,
+ ]
+
+ def _disable_controls_during_operation(self):
+ """Disable all actionable controls during install/configure operations (except Cancel)"""
+ for control in self._actionable_controls:
+ if control:
+ control.setEnabled(False)
+
+ def _enable_controls_after_operation(self):
+ """Re-enable all actionable controls after install/configure operations complete"""
+ for control in self._actionable_controls:
+ if control:
+ control.setEnabled(True)
+
+ def refresh_paths(self):
+ """Refresh cached paths when config changes."""
+ from jackify.shared.paths import get_jackify_logs_dir
+ self.modlist_log_path = get_jackify_logs_dir() / 'TTW_Install_workflow.log'
+ os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
+
+ def set_modlist_integration_mode(self, modlist_name: str, install_dir: str):
+ """Set the screen to modlist integration mode
+
+ This mode is activated when TTW needs to be installed and integrated
+ into an existing modlist. In this mode, after TTW installation completes,
+ the TTW output will be automatically integrated into the modlist.
+
+ Args:
+ modlist_name: Name of the modlist to integrate TTW into
+ install_dir: Installation directory of the modlist
+ """
+ self._integration_mode = True
+ self._integration_modlist_name = modlist_name
+ self._integration_install_dir = install_dir
+
+ # Reset saved geometry so showEvent can properly collapse from current window size
+ self._saved_geometry = None
+ self._saved_min_size = None
+
+ # Update UI to show integration mode
+ debug_print(f"TTW screen set to integration mode for modlist: {modlist_name}")
+ debug_print(f"Installation directory: {install_dir}")
+
+ def _open_url_safe(self, url):
+ """Safely open URL using subprocess to avoid Qt library conflicts in PyInstaller"""
+ import subprocess
+ try:
+ subprocess.Popen(['xdg-open', url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ except Exception as e:
+ print(f"Warning: Could not open URL {url}: {e}")
+
+ def force_collapsed_state(self):
+ """Force the screen into its collapsed state regardless of prior layout.
+
+ This is used to resolve timing/race conditions when navigating here from
+ the end of the Install Modlist workflow, ensuring the UI opens collapsed
+ just like when launched from Additional Tasks.
+ """
+ try:
+ from PySide6.QtCore import Qt as _Qt
+ # Ensure checkbox is unchecked without emitting user-facing signals
+ if self.show_details_checkbox.isChecked():
+ self.show_details_checkbox.blockSignals(True)
+ self.show_details_checkbox.setChecked(False)
+ self.show_details_checkbox.blockSignals(False)
+ # Apply collapsed layout explicitly
+ self._toggle_console_visibility(_Qt.Unchecked)
+ # Inform parent window to collapse height
+ try:
+ self.resize_request.emit('collapse')
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def resizeEvent(self, event):
+ """Handle window resize to prioritize form over console"""
+ super().resizeEvent(event)
+ self._adjust_console_for_form_priority()
+
+ def _adjust_console_for_form_priority(self):
+ """Console now dynamically fills available space with stretch=1, no manual calculation needed"""
+ # The console automatically fills remaining space due to stretch=1 in the layout
+ # Remove any fixed height constraints to allow natural stretching
+ self.console.setMaximumHeight(16777215) # Reset to default maximum
+ # Only enforce a small minimum when details are shown; keep 0 when collapsed
+ if self.console.isVisible():
+ self.console.setMinimumHeight(50)
+ else:
+ self.console.setMinimumHeight(0)
+
+ def showEvent(self, event):
+ """Called when the widget becomes visible"""
+ super().showEvent(event)
+ debug_print(f"DEBUG: TTW showEvent - integration_mode={self._integration_mode}")
+ # Check Hoolamike status only when TTW screen is opened
+ self._check_hoolamike_status()
+
+ # Ensure initial collapsed layout each time this screen is opened
+ try:
+ from PySide6.QtCore import Qt as _Qt
+ # On Steam Deck: keep expanded layout and hide the details toggle
+ try:
+ is_steamdeck = False
+ # Check our own system_info first
+ if self.system_info and getattr(self.system_info, 'is_steamdeck', False):
+ is_steamdeck = True
+ # Fallback to checking parent window's system_info
+ elif not self.system_info:
+ parent = self.window()
+ if parent and hasattr(parent, 'system_info') and getattr(parent.system_info, 'is_steamdeck', False):
+ is_steamdeck = True
+
+ if is_steamdeck:
+ debug_print("DEBUG: Steam Deck detected, keeping expanded")
+ # Force expanded state and hide checkbox
+ if self.show_details_checkbox.isVisible():
+ self.show_details_checkbox.setVisible(False)
+ # Show console with proper sizing for Steam Deck
+ self.console.setVisible(True)
+ self.console.show()
+ self.console.setMinimumHeight(200)
+ self.console.setMaximumHeight(16777215) # Remove height limit
+ return
+ except Exception as e:
+ debug_print(f"DEBUG: Steam Deck check exception: {e}")
+ pass
+ debug_print(f"DEBUG: Checkbox checked={self.show_details_checkbox.isChecked()}")
+ if self.show_details_checkbox.isChecked():
+ self.show_details_checkbox.blockSignals(True)
+ self.show_details_checkbox.setChecked(False)
+ self.show_details_checkbox.blockSignals(False)
+ debug_print("DEBUG: Calling _toggle_console_visibility(Unchecked)")
+ self._toggle_console_visibility(_Qt.Unchecked)
+ # Force the window to compact height to eliminate bottom whitespace
+ main_window = self.window()
+ debug_print(f"DEBUG: main_window={main_window}, size={main_window.size() if main_window else None}")
+ if main_window:
+ # Save original geometry once
+ if self._saved_geometry is None:
+ self._saved_geometry = main_window.geometry()
+ debug_print(f"DEBUG: Saved geometry: {self._saved_geometry}")
+ if self._saved_min_size is None:
+ self._saved_min_size = main_window.minimumSize()
+ debug_print(f"DEBUG: Saved min size: {self._saved_min_size}")
+ # Recompute and pin upper section to its content size to avoid slack
+ try:
+ if hasattr(self, 'upper_section_widget') and self.upper_section_widget is not None:
+ self.upper_section_widget.setMaximumHeight(self.upper_section_widget.sizeHint().height())
+ except Exception:
+ pass
+ # Derive compact height from current content (tighter)
+ compact_height = max(440, min(540, self.sizeHint().height() + 20))
+ debug_print(f"DEBUG: Calculated compact_height={compact_height}, sizeHint={self.sizeHint().height()}")
+
+ # COMPLETE RESET: Clear ALL size constraints from previous screen
+ from PySide6.QtCore import QSize
+ main_window.showNormal()
+ # First, completely unlock the window
+ main_window.setMinimumSize(QSize(0, 0))
+ main_window.setMaximumSize(QSize(16777215, 16777215))
+ debug_print("DEBUG: Cleared all size constraints")
+
+ # Now set our compact constraints
+ main_window.setMinimumSize(QSize(1200, compact_height))
+ main_window.setMaximumHeight(compact_height)
+ debug_print(f"DEBUG: Set compact constraints: min=1200x{compact_height}, max_height={compact_height}")
+
+ # Force resize
+ before_size = main_window.size()
+ main_window.resize(1400, compact_height)
+ debug_print(f"DEBUG: Resized from {before_size} to {main_window.size()}")
+ # Notify parent to ensure compact
+ try:
+ self.resize_request.emit('collapse')
+ debug_print("DEBUG: Emitted resize_request collapse signal")
+ except Exception as e:
+ debug_print(f"DEBUG: Exception emitting signal: {e}")
+ pass
+ except Exception as e:
+ debug_print(f"DEBUG: showEvent exception: {e}")
+ import traceback
+ debug_print(f"DEBUG: {traceback.format_exc()}")
+ pass
+
+ def hideEvent(self, event):
+ """Called when the widget becomes hidden - ensure window constraints are cleared on Steam Deck"""
+ super().hideEvent(event)
+ try:
+ # Check if we're on Steam Deck
+ is_steamdeck = False
+ if self.system_info and getattr(self.system_info, 'is_steamdeck', False):
+ is_steamdeck = True
+ else:
+ main_window = self.window()
+ if main_window and hasattr(main_window, 'system_info'):
+ is_steamdeck = getattr(main_window.system_info, 'is_steamdeck', False)
+
+ # On Steam Deck, clear any size constraints that might have been set
+ # This prevents window size issues affecting other screens after exiting TTW screen
+ if is_steamdeck:
+ debug_print("DEBUG: Steam Deck detected in hideEvent, clearing window constraints")
+ main_window = self.window()
+ if main_window:
+ from PySide6.QtCore import QSize
+ # Clear any size constraints that might have been set
+ main_window.setMaximumSize(QSize(16777215, 16777215))
+ main_window.setMinimumSize(QSize(0, 0))
+ except Exception as e:
+ debug_print(f"DEBUG: hideEvent exception: {e}")
+ pass
+
+ def _load_saved_parent_directories(self):
+ """No-op: do not pre-populate install/download directories from saved values."""
+ pass
+
+ def _update_directory_suggestions(self, modlist_name):
+ """Update directory suggestions based on modlist name"""
+ try:
+ if not modlist_name:
+ return
+
+ # Update install directory suggestion with modlist name
+ saved_install_parent = self.config_handler.get_default_install_parent_dir()
+ if saved_install_parent:
+ suggested_install_dir = os.path.join(saved_install_parent, modlist_name)
+ self.install_dir_edit.setText(suggested_install_dir)
+ debug_print(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}")
+
+ # Update download directory suggestion
+ saved_download_parent = self.config_handler.get_default_download_parent_dir()
+ if saved_download_parent:
+ suggested_download_dir = os.path.join(saved_download_parent, "Downloads")
+ debug_print(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}")
+
+ except Exception as e:
+ debug_print(f"DEBUG: Error updating directory suggestions: {e}")
+
+ def _save_parent_directories(self, install_dir, downloads_dir):
+ """Removed automatic saving - user should set defaults in settings"""
+ pass
+
+
+
+
+
+
+ def browse_wabbajack_file(self):
+ file, _ = QFileDialog.getOpenFileName(self, "Select TTW .mpi File", os.path.expanduser("~"), "MPI Files (*.mpi);;All Files (*)")
+ if file:
+ self.file_edit.setText(file)
+
+ def browse_install_dir(self):
+ dir = QFileDialog.getExistingDirectory(self, "Select Install Directory", self.install_dir_edit.text())
+ if dir:
+ self.install_dir_edit.setText(dir)
+
+
+ def go_back(self):
+ if self.stacked_widget:
+ self.stacked_widget.setCurrentIndex(self.main_menu_index)
+
+ def update_top_panel(self):
+ try:
+ result = subprocess.run([
+ "ps", "-eo", "pcpu,pmem,comm,args"
+ ], stdout=subprocess.PIPE, text=True, timeout=2)
+ lines = result.stdout.splitlines()
+ header = "CPU%\tMEM%\tCOMMAND"
+ filtered = [header]
+ process_rows = []
+ for line in lines[1:]:
+ 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 or
+ "hoolamike" in line_lower)
+ and "jackify-gui.py" not in line_lower
+ ):
+ cols = line.strip().split(None, 3)
+ if len(cols) >= 3:
+ process_rows.append(cols)
+ process_rows.sort(key=lambda x: float(x[0]), reverse=True)
+ for cols in process_rows:
+ filtered.append('\t'.join(cols))
+ if len(filtered) == 1:
+ filtered.append("[No Jackify-related processes found]")
+ self.process_monitor.setPlainText('\n'.join(filtered))
+ except Exception as e:
+ self.process_monitor.setPlainText(f"[process info unavailable: {e}]")
+
+ def _check_protontricks(self):
+ """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",
+ f"Unable to verify protontricks installation: {e}\n\n"
+ "Continuing anyway, but some features may not work correctly.")
+ return True # Continue anyway
+
+
+
+ def validate_and_start_install(self):
+ 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()
+ debug_print('DEBUG: Reloaded config from disk')
+
+ # Check TTW requirements first
+ if not self._check_ttw_requirements():
+ return
+
+ # Check protontricks before proceeding
+ if not self._check_protontricks():
+ return
+
+ # Disable all controls during installation (except Cancel)
+ self._disable_controls_during_operation()
+
+ try:
+ # TTW only needs .mpi file
+ mpi_path = self.file_edit.text().strip()
+ if not mpi_path or not os.path.isfile(mpi_path) or not mpi_path.endswith('.mpi'):
+ MessageService.warning(self, "Invalid TTW File", "Please select a valid TTW .mpi file.")
+ self._enable_controls_after_operation()
+ return
+ install_dir = self.install_dir_edit.text().strip()
+
+ # Validate required fields
+ missing_fields = []
+ if not install_dir:
+ missing_fields.append("Install Directory")
+ if missing_fields:
+ MessageService.warning(self, "Missing Required Fields", f"Please fill in all required fields before starting the install:\n- " + "\n- ".join(missing_fields))
+ self._enable_controls_after_operation()
+ return
+
+ # Validate install directory
+ validation_handler = ValidationHandler()
+ from pathlib import Path
+ is_safe, reason = validation_handler.is_safe_install_directory(Path(install_dir))
+ if not is_safe:
+ dlg = WarningDialog(reason, parent=self)
+ if not dlg.exec() or not dlg.confirmed:
+ return
+ if not os.path.isdir(install_dir):
+ create = MessageService.question(self, "Create Directory?",
+ f"The install directory does not exist:\n{install_dir}\n\nWould you like to create it?",
+ critical=False # Non-critical, won't steal focus
+ )
+ if create == QMessageBox.Yes:
+ try:
+ os.makedirs(install_dir, exist_ok=True)
+ except Exception as e:
+ MessageService.critical(self, "Error", f"Failed to create install directory:\n{e}")
+ return
+ else:
+ return
+
+ # Start TTW installation
+ self.console.clear()
+ self.process_monitor.clear()
+
+ # Update button states for installation
+ self.start_btn.setEnabled(False)
+ self.cancel_btn.setVisible(False)
+ self.cancel_install_btn.setVisible(True)
+
+ debug_print(f'DEBUG: Calling run_ttw_installer with mpi_path={mpi_path}, install_dir={install_dir}')
+ self.run_ttw_installer(mpi_path, install_dir)
+ except Exception as e:
+ debug_print(f"DEBUG: Exception in validate_and_start_install: {e}")
+ import traceback
+ debug_print(f"DEBUG: Traceback: {traceback.format_exc()}")
+ # Re-enable all controls after exception
+ self._enable_controls_after_operation()
+ self.cancel_btn.setVisible(True)
+ self.cancel_install_btn.setVisible(False)
+ debug_print(f"DEBUG: Controls re-enabled in exception handler")
+
+ def run_ttw_installer(self, mpi_path, install_dir):
+ debug_print('DEBUG: run_ttw_installer called - USING THREADED BACKEND WRAPPER')
+
+ # 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()
+
+ # Rotate log file at start of each workflow run (keep 5 backups)
+ from jackify.backend.handlers.logging_handler import LoggingHandler
+ from pathlib import Path
+ log_handler = LoggingHandler()
+ log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5)
+
+ # Clear console for fresh installation output
+ self.console.clear()
+ self._safe_append_text("Starting TTW installation...")
+
+ # Show status banner and show details checkbox
+ self.status_banner.setVisible(True)
+ self.status_banner.setText("Initializing TTW installation...")
+ self.show_details_checkbox.setVisible(True)
+
+ # Reset banner to default blue color for new installation
+ self.status_banner.setStyleSheet(f"""
+ background-color: #2a2a2a;
+ color: {JACKIFY_COLOR_BLUE};
+ padding: 8px;
+ border-radius: 4px;
+ font-weight: bold;
+ font-size: 13px;
+ """)
+
+ self.ttw_start_time = time.time()
+
+ # Start a timer to update elapsed time
+ self.ttw_elapsed_timer = QTimer()
+ self.ttw_elapsed_timer.timeout.connect(self._update_ttw_elapsed_time)
+ self.ttw_elapsed_timer.start(1000) # Update every second
+
+ # Update UI state for installation
+ self.start_btn.setEnabled(False)
+ self.cancel_btn.setVisible(False)
+ self.cancel_install_btn.setVisible(True)
+
+ # Create installation thread
+ from PySide6.QtCore import QThread, Signal
+
+ class TTWInstallationThread(QThread):
+ output_received = Signal(str)
+ progress_received = Signal(str)
+ installation_finished = Signal(bool, str)
+
+ def __init__(self, mpi_path, install_dir):
+ super().__init__()
+ self.mpi_path = mpi_path
+ self.install_dir = install_dir
+ self.cancelled = False
+ self.proc = None
+
+ def cancel(self):
+ self.cancelled = True
+ try:
+ if self.proc and self.proc.poll() is None:
+ self.proc.terminate()
+ except Exception:
+ pass
+
+ def run(self):
+ try:
+ from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler
+ from jackify.backend.handlers.filesystem_handler import FileSystemHandler
+ from jackify.backend.handlers.config_handler import ConfigHandler
+ from jackify.backend.models.configuration import SystemInfo
+ from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
+ import subprocess, sys
+
+ # Prepare backend config (do not run process here)
+ filesystem_handler = FileSystemHandler()
+ config_handler = ConfigHandler()
+ system_info = SystemInfo(is_steamdeck=False)
+ hoolamike_handler = HoolamikeHandler(
+ steamdeck=False,
+ verbose=False,
+ filesystem_handler=filesystem_handler,
+ config_handler=config_handler
+ )
+
+ # Update config for TTW and save
+ hoolamike_handler._update_hoolamike_config_for_ttw(
+ Path(self.mpi_path), Path(self.install_dir)
+ )
+ if not hoolamike_handler.save_hoolamike_config():
+ self.installation_finished.emit(False, "Failed to save hoolamike.yaml")
+ return
+
+ hoolamike_handler._check_hoolamike_installation()
+ if not hoolamike_handler.hoolamike_executable_path:
+ self.installation_finished.emit(False, "Hoolamike executable not found. Please install Hoolamike.")
+ return
+
+ cmd = [str(hoolamike_handler.hoolamike_executable_path), "tale-of-two-wastelands"]
+ env = get_clean_subprocess_env()
+
+ # Use info level to get progress bar updates from indicatif
+ # Our output filtering will parse the progress indicators
+ env['RUST_LOG'] = 'info'
+
+ cwd = str(hoolamike_handler.hoolamike_app_install_path)
+
+ # Stream output live to GUI
+ self.proc = subprocess.Popen(
+ cmd,
+ cwd=cwd,
+ env=env,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ bufsize=1,
+ universal_newlines=True,
+ )
+
+ assert self.proc.stdout is not None
+ for line in self.proc.stdout:
+ if self.cancelled:
+ break
+ self.output_received.emit(line.rstrip())
+
+ returncode = self.proc.wait()
+ if self.cancelled:
+ self.installation_finished.emit(False, "Installation cancelled by user")
+ elif returncode == 0:
+ self.installation_finished.emit(True, "TTW installation completed successfully!")
+ else:
+ self.installation_finished.emit(False, f"TTW installation failed with exit code {returncode}")
+
+ except Exception as e:
+ self.installation_finished.emit(False, f"Installation error: {str(e)}")
+
+ # Start the installation thread
+ self.install_thread = TTWInstallationThread(mpi_path, install_dir)
+ self.install_thread.output_received.connect(self.on_installation_output)
+ self.install_thread.progress_received.connect(self.on_installation_progress)
+ self.install_thread.installation_finished.connect(self.on_installation_finished)
+ self.install_thread.start()
+
+ def on_installation_output(self, message):
+ """Handle regular output from installation thread with smart progress parsing"""
+ # Filter out internal status messages from user console
+ if message.strip().startswith('[Jackify]'):
+ # Log internal messages to file but don't show in console
+ self._write_to_log_file(message)
+ return
+
+ # Strip ANSI terminal control codes (cursor movement, line clearing, etc.)
+ cleaned = strip_ansi_control_codes(message).strip()
+
+ # Filter out empty lines after stripping control codes
+ if not cleaned:
+ return
+
+ # If user asked to see details, show the raw cleaned line first (INFO-level verbosity)
+ try:
+ if self.show_details_checkbox.isChecked():
+ self._safe_append_text(cleaned)
+ except Exception:
+ pass
+
+ import re
+
+ # Try to extract total asset count from the completion message
+ success_match = re.search(r'succesfully installed \[(\d+)\] assets', cleaned)
+ if success_match:
+ total = int(success_match.group(1))
+ if not hasattr(self, 'ttw_asset_count'):
+ self.ttw_asset_count = 0
+
+ # Cache this total for future installs in config
+ from jackify.backend.handlers.config_handler import ConfigHandler
+ config_handler = ConfigHandler()
+ config_handler.set('ttw_asset_count_cache', total)
+
+ self._safe_append_text(f"\nInstallation complete: {total} assets processed successfully!")
+ return
+
+ # Parse progress bar lines: "▕bar▏(123/456 ETA 10m ELAPSED 5m) handling_assets"
+ progress_match = re.search(r'\((\d+)/(\d+)\s+ETA\s+([^\)]+)\)\s*(.*)', cleaned)
+ if progress_match:
+ current = int(progress_match.group(1))
+ total = int(progress_match.group(2))
+
+ # Store total for later use
+ if not hasattr(self, 'ttw_total_assets'):
+ self.ttw_total_assets = total
+
+ task = progress_match.group(4).strip() or "Processing"
+ percent = int((current / total) * 100) if total > 0 else 0
+ elapsed = int(time.time() - self.ttw_start_time) if hasattr(self, 'ttw_start_time') else 0
+ elapsed_min = elapsed // 60
+ elapsed_sec = elapsed % 60
+
+ self.status_banner.setText(
+ f"{task}: {current}/{total} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s"
+ )
+
+ # Show progress updates every 100 assets in console (keep it minimal)
+ if current % 100 == 0:
+ self._safe_append_text(f"Progress: {current}/{total} assets ({percent}%)")
+ return
+
+ lower_cleaned = cleaned.lower()
+
+ # Detect phases and extract useful information
+ if 'extracting_manifest' in cleaned:
+ self._safe_append_text("Extracting TTW manifest from .mpi file...")
+ return
+
+ if 'handling_assets_for_location' in cleaned:
+ # Parse location being processed
+ location_match = re.search(r'location=([^}]+)', cleaned)
+ if location_match:
+ location = location_match.group(1).strip()
+ self._safe_append_text(f"Processing location: {location}")
+ return
+
+ if 'building_archive' in cleaned:
+ self._safe_append_text("Building BSA archives...")
+ return
+
+ # Filter out variable resolution spam (MAGICALLY messages)
+ if 'magically' in lower_cleaned or 'variable_name=' in cleaned or 'resolve_variable' in cleaned:
+ # Extract total from manifest if present
+ if 'got manifest file' in lower_cleaned:
+ self._safe_append_text("Loading TTW manifest...")
+ return
+
+ # Use known asset count for TTW 3.4
+ # Actual count: 215,396 assets (measured from complete installation of TTW 3.4)
+ # This will need updating if TTW releases a new version
+ if 'got manifest file' in lower_cleaned and not hasattr(self, 'ttw_total_assets'):
+ self.ttw_total_assets = 215396
+ self._safe_append_text(f"Loading TTW manifest ({self.ttw_total_assets:,} assets)...")
+ return
+
+ # Filter out ALL per-asset processing messages
+ if 'handling_asset{kind=' in cleaned:
+ # Track progress by counting these messages
+ if not hasattr(self, 'ttw_asset_count'):
+ self.ttw_asset_count = 0
+ self.ttw_asset_count += 1
+
+ # Update banner every 10 assets processed
+ if self.ttw_asset_count % 10 == 0:
+ elapsed = int(time.time() - self.ttw_start_time) if hasattr(self, 'ttw_start_time') else 0
+ elapsed_min = elapsed // 60
+ elapsed_sec = elapsed % 60
+
+ # Show with total if we have it
+ if hasattr(self, 'ttw_total_assets'):
+ percent = int((self.ttw_asset_count / self.ttw_total_assets) * 100)
+ self.status_banner.setText(
+ f"Processing assets... {self.ttw_asset_count}/{self.ttw_total_assets} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s"
+ )
+ else:
+ self.status_banner.setText(
+ f"Processing assets... ({self.ttw_asset_count} completed) | Elapsed: {elapsed_min}m {elapsed_sec}s"
+ )
+
+ return # Don't show per-asset messages in console
+
+ # Filter out per-file verbose messages
+ if 'wrote [' in cleaned and 'bytes]' in cleaned:
+ return
+
+ if '[ok]' in lower_cleaned:
+ return # Skip all [OK] messages
+
+ # Filter out version/metadata spam at start
+ if any(x in lower_cleaned for x in ['install:installing_ttw', 'title=', 'version=', 'author=', 'description=']):
+ if 'installing_ttw{' in cleaned:
+ # Extract just the version/title cleanly
+ version_match = re.search(r'version=([\d.]+)\s+title=([^}]+)', cleaned)
+ if version_match:
+ self._safe_append_text(f"Installing {version_match.group(2)} v{version_match.group(1)}")
+ return
+
+ # Keep important messages: errors, warnings, completions
+ important_keywords = [
+ 'error', 'warning', 'failed', 'patch applied'
+ ]
+
+ # Show only important messages
+ if any(kw in lower_cleaned for kw in important_keywords):
+ # Strip out emojis if present
+ cleaned_no_emoji = re.sub(r'[⭐☢️🩹]', '', cleaned)
+ self._safe_append_text(cleaned_no_emoji.strip())
+
+ # Auto-expand console on errors/warnings
+ if any(kw in lower_cleaned for kw in ['error', 'warning', 'failed']):
+ if not self.show_details_checkbox.isChecked():
+ self.show_details_checkbox.setChecked(True)
+
+ def on_installation_progress(self, progress_message):
+ """Replace the last line in the console for progress updates"""
+ cursor = self.console.textCursor()
+ cursor.movePosition(QTextCursor.End)
+ cursor.movePosition(QTextCursor.StartOfLine, QTextCursor.KeepAnchor)
+ cursor.removeSelectedText()
+ cursor.insertText(progress_message)
+ # Don't force scroll for progress updates - let user control
+
+ def _update_ttw_elapsed_time(self):
+ """Update status banner with elapsed time"""
+ if hasattr(self, 'ttw_start_time'):
+ elapsed = int(time.time() - self.ttw_start_time)
+ minutes = elapsed // 60
+ seconds = elapsed % 60
+ self.status_banner.setText(f"Processing Tale of Two Wastelands installation... Elapsed: {minutes}m {seconds}s")
+
+ def on_installation_finished(self, success, message):
+ """Handle installation completion"""
+ debug_print(f"DEBUG: on_installation_finished called with success={success}, message={message}")
+
+ # Stop elapsed timer
+ if hasattr(self, 'ttw_elapsed_timer'):
+ self.ttw_elapsed_timer.stop()
+
+ # Update status banner
+ if success:
+ elapsed = int(time.time() - self.ttw_start_time) if hasattr(self, 'ttw_start_time') else 0
+ minutes = elapsed // 60
+ seconds = elapsed % 60
+ self.status_banner.setText(f"Installation completed successfully! Total time: {minutes}m {seconds}s")
+ self.status_banner.setStyleSheet(f"""
+ background-color: #1a4d1a;
+ color: #4CAF50;
+ padding: 8px;
+ border-radius: 4px;
+ font-weight: bold;
+ font-size: 13px;
+ """)
+ self._safe_append_text(f"\nSuccess: {message}")
+ self.process_finished(0, QProcess.NormalExit)
+ else:
+ self.status_banner.setText(f"Installation failed: {message}")
+ self.status_banner.setStyleSheet(f"""
+ background-color: #4d1a1a;
+ color: #f44336;
+ padding: 8px;
+ border-radius: 4px;
+ font-weight: bold;
+ font-size: 13px;
+ """)
+ self._safe_append_text(f"\nError: {message}")
+ self.process_finished(1, QProcess.CrashExit)
+
+ def process_finished(self, exit_code, exit_status):
+ debug_print(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}")
+ # Reset button states
+ self.start_btn.setEnabled(True)
+ self.cancel_btn.setVisible(True)
+ self.cancel_install_btn.setVisible(False)
+ debug_print("DEBUG: Button states reset in process_finished")
+
+
+ if exit_code == 0:
+ # TTW installation complete
+ self._safe_append_text("\nTTW installation completed successfully!")
+ self._safe_append_text("The merged TTW files have been created in the output directory.")
+
+ # Check if we're in modlist integration mode
+ if self._integration_mode:
+ self._safe_append_text("\nIntegrating TTW into modlist...")
+ self._perform_modlist_integration()
+ else:
+ # Standard mode - ask user if they want to create a mod archive for MO2
+ reply = MessageService.question(
+ self, "TTW Installation Complete!",
+ "Tale of Two Wastelands installation completed successfully!\n\n"
+ f"Output location: {self.install_dir_edit.text()}\n\n"
+ "Would you like to create a zipped mod archive for MO2?\n"
+ "This will package the TTW files for easy installation into Mod Organizer 2.",
+ critical=False
+ )
+
+ if reply == QMessageBox.Yes:
+ self._create_ttw_mod_archive()
+ else:
+ MessageService.information(
+ self, "Installation Complete",
+ "TTW installation complete!\n\n"
+ "You can manually use the TTW files from the output directory.",
+ safety_level="medium"
+ )
+ else:
+ # Check for user cancellation first
+ last_output = self.console.toPlainText()
+ if "cancelled by user" in last_output.lower():
+ MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low")
+ else:
+ MessageService.critical(self, "Install Failed", "The modlist install failed. Please check the console output for details.")
+ self._safe_append_text(f"\nInstall failed (exit code {exit_code}).")
+ self.console.moveCursor(QTextCursor.End)
+
+ def _setup_scroll_tracking(self):
+ """Set up scroll tracking for professional auto-scroll behavior"""
+ scrollbar = self.console.verticalScrollBar()
+ scrollbar.sliderPressed.connect(self._on_scrollbar_pressed)
+ scrollbar.sliderReleased.connect(self._on_scrollbar_released)
+ scrollbar.valueChanged.connect(self._on_scrollbar_value_changed)
+
+ def _on_scrollbar_pressed(self):
+ """User started manually scrolling"""
+ self._user_manually_scrolled = True
+
+ def _on_scrollbar_released(self):
+ """User finished manually scrolling"""
+ self._user_manually_scrolled = False
+
+ def _on_scrollbar_value_changed(self):
+ """Track if user is at bottom of scroll area"""
+ scrollbar = self.console.verticalScrollBar()
+ # Use tolerance to account for rounding and rapid updates
+ self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1
+
+ # If user manually scrolls to bottom, reset manual scroll flag
+ if self._was_at_bottom and self._user_manually_scrolled:
+ # Small delay to allow user to scroll away if they want
+ from PySide6.QtCore import QTimer
+ QTimer.singleShot(100, self._reset_manual_scroll_if_at_bottom)
+
+ def _reset_manual_scroll_if_at_bottom(self):
+ """Reset manual scroll flag if user is still at bottom after delay"""
+ scrollbar = self.console.verticalScrollBar()
+ if scrollbar.value() >= scrollbar.maximum() - 1:
+ self._user_manually_scrolled = False
+
+ def _on_show_details_toggled(self, checked: bool):
+ from PySide6.QtCore import Qt as _Qt
+ self._toggle_console_visibility(_Qt.Checked if checked else _Qt.Unchecked)
+
+ def _toggle_console_visibility(self, state):
+ """Toggle console visibility and resize main window"""
+ is_checked = (state == Qt.Checked)
+ main_window = self.window()
+
+ if not main_window:
+ return
+
+ # Check if we're on Steam Deck
+ is_steamdeck = False
+ if self.system_info and getattr(self.system_info, 'is_steamdeck', False):
+ is_steamdeck = True
+ elif not self.system_info and main_window and hasattr(main_window, 'system_info'):
+ is_steamdeck = getattr(main_window.system_info, 'is_steamdeck', False)
+
+ # Console height when expanded
+ console_height = 300
+
+ if is_checked:
+ # Show console
+ self.console.setVisible(True)
+ self.console.show()
+ self.console.setMinimumHeight(200)
+ self.console.setMaximumHeight(16777215)
+ try:
+ self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+ except Exception:
+ pass
+ try:
+ self.main_overall_vbox.setStretchFactor(self.console, 1)
+ except Exception:
+ pass
+
+ # On Steam Deck, skip window resizing - keep default Steam Deck window size
+ if is_steamdeck:
+ debug_print("DEBUG: Steam Deck detected, skipping window resize in _toggle_console_visibility")
+ return
+
+ # Restore main window to normal size (clear any compact constraints)
+ main_window.showNormal()
+ main_window.setMaximumHeight(16777215)
+ main_window.setMinimumHeight(0)
+ # Restore original minimum size so the window can expand normally
+ try:
+ if self._saved_min_size is not None:
+ main_window.setMinimumSize(self._saved_min_size)
+ except Exception:
+ pass
+ # Prefer exact original geometry if known
+ if self._saved_geometry is not None:
+ main_window.setGeometry(self._saved_geometry)
+ else:
+ expanded_min = 900
+ current_size = main_window.size()
+ target_height = max(expanded_min, 900)
+ main_window.setMinimumHeight(expanded_min)
+ main_window.resize(current_size.width(), target_height)
+ try:
+ # Encourage layouts to recompute sizes
+ self.main_overall_vbox.invalidate()
+ self.updateGeometry()
+ except Exception:
+ pass
+ # Notify parent to expand
+ try:
+ self.resize_request.emit('expand')
+ except Exception:
+ pass
+ else:
+ # Hide console fully (removes it from layout sizing)
+ self.console.setVisible(False)
+ self.console.hide()
+ self.console.setMinimumHeight(0)
+ self.console.setMaximumHeight(0)
+ try:
+ # Make the hidden console contribute no expand pressure
+ self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
+ except Exception:
+ pass
+ try:
+ self.main_overall_vbox.setStretchFactor(self.console, 0)
+ except Exception:
+ pass
+
+ # On Steam Deck, skip window resizing to keep maximized state
+ if is_steamdeck:
+ debug_print("DEBUG: Steam Deck detected, skipping window resize in collapse branch")
+ return
+
+ # Shrink main window to a compact height so no extra space remains
+ # Use the screen's sizeHint to choose a minimal-but-safe height (tighter)
+ size_hint = self.sizeHint().height()
+ new_min_height = max(440, min(540, size_hint + 20))
+ main_window.showNormal()
+ # Temporarily clamp max to enforce the smaller collapsed size; parent clears on expand
+ main_window.setMaximumHeight(new_min_height)
+ main_window.setMinimumHeight(new_min_height)
+ # Lower the main window minimum size vertically so it can collapse
+ try:
+ from PySide6.QtCore import QSize
+ current_min = self._saved_min_size or main_window.minimumSize()
+ main_window.setMinimumSize(QSize(current_min.width(), new_min_height))
+ except Exception:
+ pass
+
+ # Resize to compact height to avoid leftover space
+ current_size = main_window.size()
+ main_window.resize(current_size.width(), new_min_height)
+ try:
+ self.main_overall_vbox.invalidate()
+ self.updateGeometry()
+ except Exception:
+ pass
+ # Notify parent to collapse
+ try:
+ self.resize_request.emit('collapse')
+ except Exception:
+ pass
+
+ def _safe_append_text(self, text):
+ """Append text with professional auto-scroll behavior"""
+ # Write all messages to log file (including internal messages)
+ self._write_to_log_file(text)
+
+ # Filter out internal status messages from user console display
+ if text.strip().startswith('[Jackify]'):
+ # Internal messages are logged but not shown in user console
+ return
+
+ scrollbar = self.console.verticalScrollBar()
+ # Check if user was at bottom BEFORE adding text
+ was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance
+
+ # Add the text
+ self.console.append(text)
+
+ # Auto-scroll if user was at bottom and hasn't manually scrolled
+ # Re-check bottom state after text addition for better reliability
+ if (was_at_bottom and not self._user_manually_scrolled) or \
+ (not self._user_manually_scrolled and scrollbar.value() >= scrollbar.maximum() - 2):
+ scrollbar.setValue(scrollbar.maximum())
+ # Ensure user can still manually scroll up during rapid updates
+ if scrollbar.value() == scrollbar.maximum():
+ self._was_at_bottom = True
+
+ def _write_to_log_file(self, message):
+ """Write message to workflow log file with timestamp"""
+ try:
+ from datetime import datetime
+ timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+ with open(self.modlist_log_path, 'a', encoding='utf-8') as f:
+ f.write(f"[{timestamp}] {message}\n")
+ except Exception:
+ # Logging should never break the workflow
+ pass
+
+ def restart_steam_and_configure(self):
+ """Restart Steam using backend service directly - DECOUPLED FROM CLI"""
+ debug_print("DEBUG: restart_steam_and_configure called - using direct backend service")
+ progress = QProgressDialog("Restarting Steam...", None, 0, 0, self)
+ progress.setWindowTitle("Restarting Steam")
+ progress.setWindowModality(Qt.WindowModal)
+ progress.setMinimumDuration(0)
+ progress.setValue(0)
+ progress.show()
+
+ def do_restart():
+ debug_print("DEBUG: do_restart thread started - using direct backend service")
+ try:
+ from jackify.backend.handlers.shortcut_handler import ShortcutHandler
+
+ # Use backend service directly instead of CLI subprocess
+ shortcut_handler = ShortcutHandler(steamdeck=False) # TODO: Use proper system info
+
+ debug_print("DEBUG: About to call secure_steam_restart()")
+ success = shortcut_handler.secure_steam_restart()
+ debug_print(f"DEBUG: secure_steam_restart() returned: {success}")
+
+ out = "Steam restart completed successfully." if success else "Steam restart failed."
+
+ except Exception as e:
+ debug_print(f"DEBUG: Exception in do_restart: {e}")
+ success = False
+ out = str(e)
+
+ self.steam_restart_finished.emit(success, out)
+
+ threading.Thread(target=do_restart, daemon=True).start()
+ self._steam_restart_progress = progress # Store to close later
+
+ def _on_steam_restart_finished(self, success, out):
+ debug_print("DEBUG: _on_steam_restart_finished called")
+ # Safely cleanup progress dialog on main thread
+ if hasattr(self, '_steam_restart_progress') and self._steam_restart_progress:
+ try:
+ self._steam_restart_progress.close()
+ self._steam_restart_progress.deleteLater() # Use deleteLater() for safer cleanup
+ except Exception as e:
+ debug_print(f"DEBUG: Error closing progress dialog: {e}")
+ finally:
+ self._steam_restart_progress = None
+
+ # Controls are managed by the proper control management system
+ if success:
+ self._safe_append_text("Steam restarted successfully.")
+
+ # Save context for later use in configuration
+ self._manual_steps_retry_count = 0
+ self._current_modlist_name = "TTW Installation" # Fixed name for TTW
+ self._current_resolution = None # TTW doesn't need resolution changes
+
+ # Use automated prefix creation instead of manual steps
+ debug_print("DEBUG: Starting automated prefix creation workflow")
+ self._safe_append_text("Starting automated prefix creation workflow...")
+ self.start_automated_prefix_workflow()
+ else:
+ self._safe_append_text("Failed to restart Steam.\n" + out)
+ MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.")
+
+ def start_automated_prefix_workflow(self):
+ # Ensure _current_resolution is always set before starting workflow
+ if not hasattr(self, '_current_resolution') or self._current_resolution is None:
+ resolution = None # TTW doesn't need resolution changes
+ # Extract resolution properly (e.g., "1280x800" from "1280x800 (Steam Deck)")
+ if resolution and resolution != "Leave unchanged":
+ if " (" in resolution:
+ self._current_resolution = resolution.split(" (")[0]
+ else:
+ 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()
+ modlist_name = "TTW Installation"
+ install_dir = self.install_dir_edit.text().strip()
+ final_exe_path = os.path.join(install_dir, "ModOrganizer.exe")
+
+ if not os.path.exists(final_exe_path):
+ # Check if this is Somnium specifically (uses files/ subdirectory)
+ modlist_name_lower = modlist_name.lower()
+ if "somnium" in modlist_name_lower:
+ somnium_exe_path = os.path.join(install_dir, "files", "ModOrganizer.exe")
+ if os.path.exists(somnium_exe_path):
+ final_exe_path = somnium_exe_path
+ self._safe_append_text(f"Detected Somnium modlist - will proceed with automated setup")
+ # Show Somnium guidance popup after automated workflow completes
+ self._show_somnium_guidance = True
+ self._somnium_install_dir = install_dir
+ else:
+ self._safe_append_text(f"ERROR: Somnium ModOrganizer.exe not found at {somnium_exe_path}")
+ MessageService.critical(self, "Somnium ModOrganizer.exe Not Found",
+ f"Expected Somnium ModOrganizer.exe not found at:\n{somnium_exe_path}\n\nCannot proceed with automated setup.")
+ return
+ else:
+ self._safe_append_text(f"ERROR: ModOrganizer.exe not found at {final_exe_path}")
+ MessageService.critical(self, "ModOrganizer.exe Not Found",
+ f"ModOrganizer.exe not found at:\n{final_exe_path}\n\nCannot proceed with automated setup.")
+ return
+
+ # Run automated prefix creation in separate thread
+ from PySide6.QtCore import QThread, Signal
+
+ class AutomatedPrefixThread(QThread):
+ finished = Signal(bool, str, str, str) # success, prefix_path, appid (as string), last_timestamp
+ progress = Signal(str) # progress messages
+ error = Signal(str) # error messages
+ show_progress_dialog = Signal(str) # show progress dialog with message
+ hide_progress_dialog = Signal() # hide progress dialog
+ conflict_detected = Signal(list) # conflicts list
+
+ def __init__(self, modlist_name, install_dir, final_exe_path):
+ super().__init__()
+ self.modlist_name = modlist_name
+ self.install_dir = install_dir
+ self.final_exe_path = final_exe_path
+
+ def run(self):
+ try:
+ from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
+
+ def progress_callback(message):
+ self.progress.emit(message)
+ # Show progress dialog during Steam restart
+ if "Steam restarted successfully" in message:
+ self.hide_progress_dialog.emit()
+ elif "Restarting Steam..." in message:
+ self.show_progress_dialog.emit("Restarting Steam...")
+
+ prefix_service = AutomatedPrefixService()
+ # Determine Steam Deck once and pass through the workflow
+ try:
+ import os
+ _is_steamdeck = False
+ if os.path.exists('/etc/os-release'):
+ with open('/etc/os-release') as f:
+ if 'steamdeck' in f.read().lower():
+ _is_steamdeck = True
+ except Exception:
+ _is_steamdeck = False
+ result = prefix_service.run_working_workflow(
+ self.modlist_name, self.install_dir, self.final_exe_path, progress_callback, steamdeck=_is_steamdeck
+ )
+
+ # Handle the result - check for conflicts
+ if isinstance(result, tuple) and len(result) == 4:
+ if result[0] == "CONFLICT":
+ # Conflict detected - emit signal to main GUI
+ conflicts = result[1]
+ self.hide_progress_dialog.emit()
+ self.conflict_detected.emit(conflicts)
+ return
+ else:
+ # Normal result with timestamp
+ success, prefix_path, new_appid, last_timestamp = result
+ elif isinstance(result, tuple) and len(result) == 3:
+ # Fallback for old format (backward compatibility)
+ if result[0] == "CONFLICT":
+ # Conflict detected - emit signal to main GUI
+ conflicts = result[1]
+ self.hide_progress_dialog.emit()
+ self.conflict_detected.emit(conflicts)
+ return
+ else:
+ # Normal result (old format)
+ success, prefix_path, new_appid = result
+ last_timestamp = None
+ else:
+ # Handle non-tuple result
+ success = result
+ prefix_path = ""
+ new_appid = "0"
+ last_timestamp = None
+
+ # Ensure progress dialog is hidden when workflow completes
+ self.hide_progress_dialog.emit()
+ self.finished.emit(success, prefix_path or "", str(new_appid) if new_appid else "0", last_timestamp)
+
+ except Exception as e:
+ # Ensure progress dialog is hidden on error
+ self.hide_progress_dialog.emit()
+ self.error.emit(str(e))
+
+ # Create and start thread
+ self.prefix_thread = AutomatedPrefixThread(modlist_name, install_dir, final_exe_path)
+ self.prefix_thread.finished.connect(self.on_automated_prefix_finished)
+ self.prefix_thread.error.connect(self.on_automated_prefix_error)
+ self.prefix_thread.progress.connect(self.on_automated_prefix_progress)
+ self.prefix_thread.show_progress_dialog.connect(self.show_steam_restart_progress)
+ self.prefix_thread.hide_progress_dialog.connect(self.hide_steam_restart_progress)
+ self.prefix_thread.conflict_detected.connect(self.show_shortcut_conflict_dialog)
+ self.prefix_thread.start()
+
+ except Exception as e:
+ debug_print(f"DEBUG: Exception in start_automated_prefix_workflow: {e}")
+ import traceback
+ debug_print(f"DEBUG: Traceback: {traceback.format_exc()}")
+ self._safe_append_text(f"ERROR: Failed to start automated workflow: {e}")
+ # Re-enable controls on exception
+ self._enable_controls_after_operation()
+
+ def on_automated_prefix_finished(self, success, prefix_path, new_appid_str, last_timestamp=None):
+ """Handle completion of automated prefix creation"""
+ try:
+ if success:
+ debug_print(f"SUCCESS: Automated prefix creation completed!")
+ debug_print(f"Prefix created at: {prefix_path}")
+ if new_appid_str and new_appid_str != "0":
+ debug_print(f"AppID: {new_appid_str}")
+
+ # Convert string AppID back to integer for configuration
+ new_appid = int(new_appid_str) if new_appid_str and new_appid_str != "0" else None
+
+ # Continue with configuration using the new AppID and timestamp
+ modlist_name = "TTW Installation"
+ install_dir = self.install_dir_edit.text().strip()
+ self.continue_configuration_after_automated_prefix(new_appid, modlist_name, install_dir, last_timestamp)
+ else:
+ self._safe_append_text(f"ERROR: Automated prefix creation failed")
+ self._safe_append_text("Please check the logs for details")
+ MessageService.critical(self, "Automated Setup Failed",
+ "Automated prefix creation failed. Please check the console output for details.")
+ # Re-enable controls on failure
+ self._enable_controls_after_operation()
+ finally:
+ # Always ensure controls are re-enabled when workflow truly completes
+ pass
+
+ def on_automated_prefix_error(self, error_msg):
+ """Handle error in automated prefix creation"""
+ self._safe_append_text(f"ERROR: Error during automated prefix creation: {error_msg}")
+ MessageService.critical(self, "Automated Setup Error",
+ f"Error during automated prefix creation: {error_msg}")
+ # Re-enable controls on error
+ self._enable_controls_after_operation()
+
+ def on_automated_prefix_progress(self, progress_msg):
+ """Handle progress updates from automated prefix creation"""
+ self._safe_append_text(progress_msg)
+
+ def on_configuration_progress(self, progress_msg):
+ """Handle progress updates from modlist configuration"""
+ self._safe_append_text(progress_msg)
+
+ def show_steam_restart_progress(self, message):
+ """Show Steam restart progress dialog"""
+ from PySide6.QtWidgets import QProgressDialog
+ from PySide6.QtCore import Qt
+
+ self.steam_restart_progress = QProgressDialog(message, None, 0, 0, self)
+ self.steam_restart_progress.setWindowTitle("Restarting Steam")
+ self.steam_restart_progress.setWindowModality(Qt.WindowModal)
+ self.steam_restart_progress.setMinimumDuration(0)
+ self.steam_restart_progress.setValue(0)
+ self.steam_restart_progress.show()
+
+ def hide_steam_restart_progress(self):
+ """Hide Steam restart progress dialog"""
+ if hasattr(self, 'steam_restart_progress') and self.steam_restart_progress:
+ try:
+ self.steam_restart_progress.close()
+ self.steam_restart_progress.deleteLater()
+ except Exception:
+ pass
+ finally:
+ self.steam_restart_progress = None
+ # Controls are managed by the proper control management system
+
+ def on_configuration_complete(self, success, message, modlist_name):
+ """Handle configuration completion on main thread"""
+ try:
+ # Re-enable controls now that installation/configuration is complete
+ self._enable_controls_after_operation()
+
+ if success:
+ # Check if we need to show Somnium guidance
+ if self._show_somnium_guidance:
+ self._show_somnium_post_install_guidance()
+
+ # Show celebration SuccessDialog after the entire workflow
+ from ..dialogs import SuccessDialog
+ import time
+ if not hasattr(self, '_install_workflow_start_time'):
+ self._install_workflow_start_time = time.time()
+ time_taken = int(time.time() - self._install_workflow_start_time)
+ mins, secs = divmod(time_taken, 60)
+ time_str = f"{mins} minutes, {secs} seconds" if mins else f"{secs} seconds"
+ display_names = {
+ 'skyrim': 'Skyrim',
+ 'fallout4': 'Fallout 4',
+ 'falloutnv': 'Fallout New Vegas',
+ 'oblivion': 'Oblivion',
+ 'starfield': 'Starfield',
+ 'oblivion_remastered': 'Oblivion Remastered',
+ 'enderal': 'Enderal'
+ }
+ game_name = display_names.get(self._current_game_type, self._current_game_name)
+ success_dialog = SuccessDialog(
+ modlist_name=modlist_name,
+ workflow_type="install",
+ time_taken=time_str,
+ game_name=game_name,
+ parent=self
+ )
+ success_dialog.show()
+ elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3:
+ # Max retries reached - show failure message
+ MessageService.critical(self, "Manual Steps Failed",
+ "Manual steps validation failed after multiple attempts.")
+ else:
+ # Configuration failed for other reasons
+ MessageService.critical(self, "Configuration Failed",
+ "Post-install configuration failed. Please check the console output.")
+ except Exception as e:
+ # Ensure controls are re-enabled even on unexpected errors
+ self._enable_controls_after_operation()
+ raise
+ # Clean up thread
+ if hasattr(self, 'config_thread') and self.config_thread is not None:
+ # Disconnect all signals to prevent "Internal C++ object already deleted" errors
+ try:
+ self.config_thread.progress_update.disconnect()
+ self.config_thread.configuration_complete.disconnect()
+ self.config_thread.error_occurred.disconnect()
+ except:
+ pass # Ignore errors if already disconnected
+ if self.config_thread.isRunning():
+ self.config_thread.quit()
+ self.config_thread.wait(5000) # Wait up to 5 seconds
+ self.config_thread.deleteLater()
+ self.config_thread = None
+
+ def on_configuration_error(self, error_message):
+ """Handle configuration error on main thread"""
+ self._safe_append_text(f"Configuration failed with error: {error_message}")
+ MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}")
+
+ # Re-enable all controls on error
+ self._enable_controls_after_operation()
+
+ # Clean up thread
+ if hasattr(self, 'config_thread') and self.config_thread is not None:
+ # Disconnect all signals to prevent "Internal C++ object already deleted" errors
+ try:
+ self.config_thread.progress_update.disconnect()
+ self.config_thread.configuration_complete.disconnect()
+ self.config_thread.error_occurred.disconnect()
+ except:
+ pass # Ignore errors if already disconnected
+ if self.config_thread.isRunning():
+ self.config_thread.quit()
+ self.config_thread.wait(5000) # Wait up to 5 seconds
+ self.config_thread.deleteLater()
+ self.config_thread = None
+
+ def show_manual_steps_dialog(self, extra_warning=""):
+ modlist_name = "TTW Installation"
+ msg = (
+ f"Manual Proton Setup Required for {modlist_name}
"
+ "After Steam restarts, complete the following steps in Steam:
"
+ f"1. Locate the '{modlist_name}' entry in your Steam Library
"
+ "2. Right-click and select 'Properties'
"
+ "3. Switch to the 'Compatibility' tab
"
+ "4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'
"
+ "5. Select 'Proton - Experimental' from the dropdown menu
"
+ "6. Close the Properties window
"
+ f"7. Launch '{modlist_name}' from your Steam Library
"
+ "8. Wait for Mod Organizer 2 to fully open
"
+ "9. Once Mod Organizer has fully loaded, CLOSE IT completely and return here
"
+ "
Once you have completed ALL the steps above, click OK to continue."
+ f"{extra_warning}"
+ )
+ reply = MessageService.question(self, "Manual Steps Required", msg, safety_level="medium")
+ if reply == QMessageBox.Yes:
+ self.validate_manual_steps_completion()
+ else:
+ # User clicked Cancel or closed the dialog - cancel the workflow
+ self._safe_append_text("\n🛑 Manual steps cancelled by user. Workflow stopped.")
+ # Re-enable all controls when workflow is cancelled
+ self._enable_controls_after_operation()
+ self.cancel_btn.setVisible(True)
+ self.cancel_install_btn.setVisible(False)
+
+ def _get_mo2_path(self, install_dir, modlist_name):
+ """Get ModOrganizer.exe path, handling Somnium's non-standard structure"""
+ mo2_exe_path = os.path.join(install_dir, "ModOrganizer.exe")
+ if not os.path.exists(mo2_exe_path) and "somnium" in modlist_name.lower():
+ somnium_path = os.path.join(install_dir, "files", "ModOrganizer.exe")
+ if os.path.exists(somnium_path):
+ mo2_exe_path = somnium_path
+ return mo2_exe_path
+
+ def validate_manual_steps_completion(self):
+ """Validate that manual steps were actually completed and handle retry logic"""
+ modlist_name = "TTW Installation"
+ install_dir = self.install_dir_edit.text().strip()
+ mo2_exe_path = self._get_mo2_path(install_dir, modlist_name)
+
+ # Add delay to allow Steam filesystem updates to complete
+ self._safe_append_text("Waiting for Steam filesystem updates to complete...")
+ import time
+ time.sleep(2)
+
+ # CRITICAL: Re-detect the AppID after Steam restart and manual steps
+ # Steam assigns a NEW AppID during restart, different from the one we initially created
+ self._safe_append_text(f"Re-detecting AppID for shortcut '{modlist_name}' after Steam restart...")
+ from jackify.backend.handlers.shortcut_handler import ShortcutHandler
+ from jackify.backend.services.platform_detection_service import PlatformDetectionService
+
+ platform_service = PlatformDetectionService.get_instance()
+ shortcut_handler = ShortcutHandler(steamdeck=platform_service.is_steamdeck)
+ current_appid = shortcut_handler.get_appid_for_shortcut(modlist_name, mo2_exe_path)
+
+ if not current_appid or not current_appid.isdigit():
+ self._safe_append_text(f"Error: Could not find Steam-assigned AppID for shortcut '{modlist_name}'")
+ self._safe_append_text("Error: This usually means the shortcut was not launched from Steam")
+ self._safe_append_text("Suggestion: Check that Steam is running and shortcuts are visible in library")
+ self.handle_validation_failure("Could not find Steam shortcut")
+ return
+
+ self._safe_append_text(f"Found Steam-assigned AppID: {current_appid}")
+ self._safe_append_text(f"Validating manual steps completion for AppID: {current_appid}")
+
+ # Check 1: Proton version
+ proton_ok = False
+ try:
+ from jackify.backend.handlers.modlist_handler import ModlistHandler
+ from jackify.backend.handlers.path_handler import PathHandler
+
+ # Initialize ModlistHandler with correct parameters
+ path_handler = PathHandler()
+
+ # Use centralized Steam Deck detection
+ from jackify.backend.services.platform_detection_service import PlatformDetectionService
+ platform_service = PlatformDetectionService.get_instance()
+
+ modlist_handler = ModlistHandler(steamdeck=platform_service.is_steamdeck, verbose=False)
+
+ # Set required properties manually after initialization
+ modlist_handler.modlist_dir = install_dir
+ modlist_handler.appid = current_appid
+ modlist_handler.game_var = "skyrimspecialedition" # Default for now
+
+ # Set compat_data_path for Proton detection
+ compat_data_path_str = path_handler.find_compat_data(current_appid)
+ if compat_data_path_str:
+ from pathlib import Path
+ modlist_handler.compat_data_path = Path(compat_data_path_str)
+
+ # Check Proton version
+ self._safe_append_text(f"Attempting to detect Proton version for AppID {current_appid}...")
+ if modlist_handler._detect_proton_version():
+ self._safe_append_text(f"Raw detected Proton version: '{modlist_handler.proton_ver}'")
+ if modlist_handler.proton_ver and 'experimental' in modlist_handler.proton_ver.lower():
+ proton_ok = True
+ self._safe_append_text(f"Proton version validated: {modlist_handler.proton_ver}")
+ else:
+ self._safe_append_text(f"Error: Wrong Proton version detected: '{modlist_handler.proton_ver}' (expected 'experimental' in name)")
+ else:
+ self._safe_append_text("Error: Could not detect Proton version from any source")
+
+ except Exception as e:
+ self._safe_append_text(f"Error checking Proton version: {e}")
+ proton_ok = False
+
+ # Check 2: Compatdata directory exists
+ compatdata_ok = False
+ try:
+ from jackify.backend.handlers.path_handler import PathHandler
+ path_handler = PathHandler()
+
+ self._safe_append_text(f"Searching for compatdata directory for AppID {current_appid}...")
+ self._safe_append_text("Checking standard Steam locations and Flatpak Steam...")
+ prefix_path_str = path_handler.find_compat_data(current_appid)
+ self._safe_append_text(f"Compatdata search result: '{prefix_path_str}'")
+
+ if prefix_path_str and os.path.isdir(prefix_path_str):
+ compatdata_ok = True
+ self._safe_append_text(f"Compatdata directory found: {prefix_path_str}")
+ else:
+ if prefix_path_str:
+ self._safe_append_text(f"Error: Path exists but is not a directory: {prefix_path_str}")
+ else:
+ self._safe_append_text(f"Error: No compatdata directory found for AppID {current_appid}")
+ self._safe_append_text("Suggestion: Ensure you launched the shortcut from Steam at least once")
+ self._safe_append_text("Suggestion: Check if Steam is using Flatpak (different file paths)")
+
+ except Exception as e:
+ self._safe_append_text(f"Error checking compatdata: {e}")
+ compatdata_ok = False
+
+ # Handle validation results
+ if proton_ok and compatdata_ok:
+ self._safe_append_text("Manual steps validation passed!")
+ self._safe_append_text("Continuing configuration with updated AppID...")
+
+ # Continue configuration with the corrected AppID and context
+ self.continue_configuration_after_manual_steps(current_appid, modlist_name, install_dir)
+ else:
+ # Validation failed - handle retry logic
+ missing_items = []
+ if not proton_ok:
+ missing_items.append("• Proton - Experimental not set")
+ if not compatdata_ok:
+ missing_items.append("• Shortcut not launched from Steam (no compatdata)")
+
+ missing_text = "\n".join(missing_items)
+ self._safe_append_text(f"Manual steps validation failed:\n{missing_text}")
+ self.handle_validation_failure(missing_text)
+
+ def show_shortcut_conflict_dialog(self, conflicts):
+ """Show dialog to resolve shortcut name conflicts"""
+ conflict_names = [c['name'] for c in conflicts]
+ conflict_info = f"Found existing Steam shortcut: '{conflict_names[0]}'"
+
+ modlist_name = "TTW Installation"
+
+ # Create dialog with Jackify styling
+ from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout
+ from PySide6.QtCore import Qt
+
+ dialog = QDialog(self)
+ dialog.setWindowTitle("Steam Shortcut Conflict")
+ dialog.setModal(True)
+ dialog.resize(450, 180)
+
+ # Apply Jackify dark theme styling
+ dialog.setStyleSheet("""
+ QDialog {
+ background-color: #2b2b2b;
+ color: #ffffff;
+ }
+ QLabel {
+ color: #ffffff;
+ font-size: 14px;
+ padding: 10px 0px;
+ }
+ QLineEdit {
+ background-color: #404040;
+ color: #ffffff;
+ border: 2px solid #555555;
+ border-radius: 4px;
+ padding: 8px;
+ font-size: 14px;
+ selection-background-color: #3fd0ea;
+ }
+ QLineEdit:focus {
+ border-color: #3fd0ea;
+ }
+ QPushButton {
+ background-color: #404040;
+ color: #ffffff;
+ border: 2px solid #555555;
+ border-radius: 4px;
+ padding: 8px 16px;
+ font-size: 14px;
+ min-width: 120px;
+ }
+ QPushButton:hover {
+ background-color: #505050;
+ border-color: #3fd0ea;
+ }
+ QPushButton:pressed {
+ background-color: #303030;
+ }
+ """)
+
+ layout = QVBoxLayout(dialog)
+ layout.setContentsMargins(20, 20, 20, 20)
+ layout.setSpacing(15)
+
+ # Conflict message
+ conflict_label = QLabel(f"{conflict_info}\n\nPlease choose a different name for your shortcut:")
+ layout.addWidget(conflict_label)
+
+ # Text input for new name
+ name_input = QLineEdit(modlist_name)
+ name_input.selectAll()
+ layout.addWidget(name_input)
+
+ # Buttons
+ button_layout = QHBoxLayout()
+ button_layout.setSpacing(10)
+
+ create_button = QPushButton("Create with New Name")
+ cancel_button = QPushButton("Cancel")
+
+ button_layout.addStretch()
+ button_layout.addWidget(cancel_button)
+ button_layout.addWidget(create_button)
+ layout.addLayout(button_layout)
+
+ # Connect signals
+ def on_create():
+ new_name = name_input.text().strip()
+ if new_name and new_name != modlist_name:
+ dialog.accept()
+ # Retry workflow with new name
+ self.retry_automated_workflow_with_new_name(new_name)
+ elif new_name == modlist_name:
+ # Same name - show warning
+ from jackify.backend.services.message_service import MessageService
+ MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.")
+ else:
+ # Empty name
+ from jackify.backend.services.message_service import MessageService
+ MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.")
+
+ def on_cancel():
+ dialog.reject()
+ self._safe_append_text("Shortcut creation cancelled by user")
+
+ create_button.clicked.connect(on_create)
+ cancel_button.clicked.connect(on_cancel)
+
+ # Make Enter key work
+ name_input.returnPressed.connect(on_create)
+
+ dialog.exec()
+
+ def retry_automated_workflow_with_new_name(self, new_name):
+ """Retry the automated workflow with a new shortcut name"""
+ # Update the modlist name field temporarily
+ # TTW doesn't need name editing
+
+ # Restart the automated workflow
+ self._safe_append_text(f"Retrying with new shortcut name: '{new_name}'")
+ self.start_automated_prefix_workflow()
+
+ def continue_configuration_after_automated_prefix(self, new_appid, modlist_name, install_dir, last_timestamp=None):
+ """Continue the configuration process with the new AppID after automated prefix creation"""
+ # Headers are now shown at start of Steam Integration
+ # No need to show them again here
+ debug_print("Configuration phase continues after Steam Integration")
+
+ debug_print(f"continue_configuration_after_automated_prefix called with appid: {new_appid}")
+ try:
+ # Update the context with the new AppID (same format as manual steps)
+ updated_context = {
+ 'name': modlist_name,
+ 'path': install_dir,
+ 'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name),
+ 'modlist_value': None,
+ 'modlist_source': None,
+ 'resolution': getattr(self, '_current_resolution', None),
+ 'skip_confirmation': True,
+ 'manual_steps_completed': True, # Mark as completed since automated prefix is done
+ 'appid': new_appid, # Use the NEW AppID from automated prefix creation
+ 'game_name': self.context.get('game_name', 'Skyrim Special Edition') if hasattr(self, 'context') else 'Skyrim Special Edition'
+ }
+ self.context = updated_context # Ensure context is always set
+ debug_print(f"Updated context with new AppID: {new_appid}")
+
+ # Get Steam Deck detection once and pass to ConfigThread
+ from jackify.backend.services.platform_detection_service import PlatformDetectionService
+ platform_service = PlatformDetectionService.get_instance()
+ is_steamdeck = platform_service.is_steamdeck
+
+ # Create new config thread with updated context
+ class ConfigThread(QThread):
+ progress_update = Signal(str)
+ configuration_complete = Signal(bool, str, str)
+ error_occurred = Signal(str)
+
+ def __init__(self, context, is_steamdeck):
+ super().__init__()
+ self.context = context
+ self.is_steamdeck = is_steamdeck
+
+ def run(self):
+ try:
+ from jackify.backend.services.modlist_service import ModlistService
+ from jackify.backend.models.configuration import SystemInfo
+ from jackify.backend.models.modlist import ModlistContext
+ from pathlib import Path
+
+ # Initialize backend service with passed Steam Deck detection
+ system_info = SystemInfo(is_steamdeck=self.is_steamdeck)
+ modlist_service = ModlistService(system_info)
+
+ # Convert context to ModlistContext for service
+ modlist_context = ModlistContext(
+ name=self.context['name'],
+ install_dir=Path(self.context['path']),
+ download_dir=Path(self.context['path']).parent / 'Downloads', # Default
+ game_type='skyrim', # Default for now
+ nexus_api_key='', # Not needed for configuration
+ modlist_value=self.context.get('modlist_value'),
+ modlist_source=self.context.get('modlist_source', 'identifier'),
+ resolution=self.context.get('resolution'),
+ skip_confirmation=True,
+ engine_installed=True # Skip path manipulation for engine workflows
+ )
+
+ # Add app_id to context
+ modlist_context.app_id = self.context['appid']
+
+ # Define callbacks
+ def progress_callback(message):
+ self.progress_update.emit(message)
+
+ def completion_callback(success, message, modlist_name):
+ self.configuration_complete.emit(success, message, modlist_name)
+
+ def manual_steps_callback(modlist_name, retry_count):
+ # This shouldn't happen since automated prefix creation is complete
+ self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
+
+ # Call the service method for post-Steam configuration
+ result = modlist_service.configure_modlist_post_steam(
+ context=modlist_context,
+ progress_callback=progress_callback,
+ manual_steps_callback=manual_steps_callback,
+ completion_callback=completion_callback
+ )
+
+ if not result:
+ self.progress_update.emit("Configuration failed to start")
+ self.error_occurred.emit("Configuration failed to start")
+
+ except Exception as e:
+ self.error_occurred.emit(str(e))
+
+ # Start configuration thread
+ self.config_thread = ConfigThread(updated_context, is_steamdeck)
+ self.config_thread.progress_update.connect(self.on_configuration_progress)
+ self.config_thread.configuration_complete.connect(self.on_configuration_complete)
+ self.config_thread.error_occurred.connect(self.on_configuration_error)
+ self.config_thread.start()
+
+ except Exception as e:
+ self._safe_append_text(f"Error continuing configuration: {e}")
+ import traceback
+ self._safe_append_text(f"Full traceback: {traceback.format_exc()}")
+ self.on_configuration_error(str(e))
+
+
+
+ def continue_configuration_after_manual_steps(self, new_appid, modlist_name, install_dir):
+ """Continue the configuration process with the corrected AppID after manual steps validation"""
+ try:
+ # Update the context with the new AppID
+ updated_context = {
+ 'name': modlist_name,
+ 'path': install_dir,
+ 'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name),
+ 'modlist_value': None,
+ 'modlist_source': None,
+ 'resolution': getattr(self, '_current_resolution', None),
+ 'skip_confirmation': True,
+ 'manual_steps_completed': True, # Mark as completed
+ 'appid': new_appid # Use the NEW AppID from Steam
+ }
+
+ debug_print(f"Updated context with new AppID: {new_appid}")
+
+ # Clean up old thread if exists and wait for it to finish
+ if hasattr(self, 'config_thread') and self.config_thread is not None:
+ # Disconnect all signals to prevent "Internal C++ object already deleted" errors
+ try:
+ self.config_thread.progress_update.disconnect()
+ self.config_thread.configuration_complete.disconnect()
+ self.config_thread.error_occurred.disconnect()
+ except:
+ pass # Ignore errors if already disconnected
+ if self.config_thread.isRunning():
+ self.config_thread.quit()
+ self.config_thread.wait(5000) # Wait up to 5 seconds
+ self.config_thread.deleteLater()
+ self.config_thread = None
+
+ # Start new config thread
+ self.config_thread = self._create_config_thread(updated_context)
+ self.config_thread.progress_update.connect(self.on_configuration_progress)
+ self.config_thread.configuration_complete.connect(self.on_configuration_complete)
+ self.config_thread.error_occurred.connect(self.on_configuration_error)
+ self.config_thread.start()
+
+ except Exception as e:
+ self._safe_append_text(f"Error continuing configuration: {e}")
+ self.on_configuration_error(str(e))
+
+ def _create_config_thread(self, context):
+ """Create a new ConfigThread with proper lifecycle management"""
+ from PySide6.QtCore import QThread, Signal
+
+ # Get Steam Deck detection once
+ from jackify.backend.services.platform_detection_service import PlatformDetectionService
+ platform_service = PlatformDetectionService.get_instance()
+ is_steamdeck = platform_service.is_steamdeck
+
+ class ConfigThread(QThread):
+ progress_update = Signal(str)
+ configuration_complete = Signal(bool, str, str)
+ error_occurred = Signal(str)
+
+ def __init__(self, context, is_steamdeck, parent=None):
+ super().__init__(parent)
+ self.context = context
+ self.is_steamdeck = is_steamdeck
+
+ def run(self):
+ try:
+ from jackify.backend.models.configuration import SystemInfo
+ from jackify.backend.services.modlist_service import ModlistService
+ from jackify.backend.models.modlist import ModlistContext
+ from pathlib import Path
+
+ # Initialize backend service with passed Steam Deck detection
+ system_info = SystemInfo(is_steamdeck=self.is_steamdeck)
+ modlist_service = ModlistService(system_info)
+
+ # Convert context to ModlistContext for service
+ modlist_context = ModlistContext(
+ name=self.context['name'],
+ install_dir=Path(self.context['path']),
+ download_dir=Path(self.context['path']).parent / 'Downloads', # Default
+ game_type='skyrim', # Default for now
+ nexus_api_key='', # Not needed for configuration
+ modlist_value=self.context.get('modlist_value', ''),
+ modlist_source=self.context.get('modlist_source', 'identifier'),
+ resolution=self.context.get('resolution'), # Pass resolution from GUI
+ skip_confirmation=True,
+ engine_installed=True # Skip path manipulation for engine workflows
+ )
+
+ # Add app_id to context
+ if 'appid' in self.context:
+ modlist_context.app_id = self.context['appid']
+
+ # Define callbacks
+ def progress_callback(message):
+ self.progress_update.emit(message)
+
+ def completion_callback(success, message, modlist_name):
+ self.configuration_complete.emit(success, message, modlist_name)
+
+ def manual_steps_callback(modlist_name, retry_count):
+ # This shouldn't happen since manual steps should be done
+ self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
+
+ # Call the new service method for post-Steam configuration
+ result = modlist_service.configure_modlist_post_steam(
+ context=modlist_context,
+ progress_callback=progress_callback,
+ manual_steps_callback=manual_steps_callback,
+ completion_callback=completion_callback
+ )
+
+ if not result:
+ self.progress_update.emit("WARNING: configure_modlist_post_steam returned False")
+
+ except Exception as e:
+ import traceback
+ error_details = f"Error in configuration: {e}\nTraceback: {traceback.format_exc()}"
+ self.progress_update.emit(f"DEBUG: {error_details}")
+ self.error_occurred.emit(str(e))
+
+ return ConfigThread(context, is_steamdeck, parent=self)
+
+ def handle_validation_failure(self, missing_text):
+ """Handle failed validation with retry logic"""
+ self._manual_steps_retry_count += 1
+
+ if self._manual_steps_retry_count < 3:
+ # Show retry dialog with increasingly detailed guidance
+ retry_guidance = ""
+ if self._manual_steps_retry_count == 1:
+ retry_guidance = "\n\nTip: Make sure Steam is fully restarted before trying again."
+ elif self._manual_steps_retry_count == 2:
+ retry_guidance = "\n\nTip: If using Flatpak Steam, ensure compatdata is being created in the correct location."
+
+ MessageService.critical(self, "Manual Steps Incomplete",
+ f"Manual steps validation failed:\n\n{missing_text}\n\n"
+ f"Please complete the missing steps and try again.{retry_guidance}")
+ # Show manual steps dialog again
+ extra_warning = ""
+ if self._manual_steps_retry_count >= 2:
+ extra_warning = "
It looks like you have not completed the manual steps yet. Please try again."
+ self.show_manual_steps_dialog(extra_warning)
+ else:
+ # Max retries reached
+ MessageService.critical(self, "Manual Steps Failed",
+ "Manual steps validation failed after multiple attempts.\n\n"
+ "Common issues:\n"
+ "• Steam not fully restarted\n"
+ "• Shortcut not launched from Steam\n"
+ "• Flatpak Steam using different file paths\n"
+ "• Proton - Experimental not selected")
+ self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self._current_modlist_name)
+
+ def show_next_steps_dialog(self, message):
+ # EXACT LEGACY show_next_steps_dialog
+ from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout, QApplication
+ dlg = QDialog(self)
+ dlg.setWindowTitle("Next Steps")
+ dlg.setModal(True)
+ layout = QVBoxLayout(dlg)
+ label = QLabel(message)
+ label.setWordWrap(True)
+ layout.addWidget(label)
+ btn_row = QHBoxLayout()
+ btn_return = QPushButton("Return")
+ btn_exit = QPushButton("Exit")
+ btn_row.addWidget(btn_return)
+ btn_row.addWidget(btn_exit)
+ layout.addLayout(btn_row)
+ def on_return():
+ dlg.accept()
+ if self.stacked_widget:
+ self.stacked_widget.setCurrentIndex(0) # Main menu
+ def on_exit():
+ QApplication.quit()
+ btn_return.clicked.connect(on_return)
+ btn_exit.clicked.connect(on_exit)
+ dlg.exec()
+
+ def cleanup_processes(self):
+ """Clean up any running processes when the window closes or is cancelled"""
+ debug_print("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes")
+
+ # Clean up InstallationThread if running
+ if hasattr(self, 'install_thread') and self.install_thread.isRunning():
+ debug_print("DEBUG: Cancelling running InstallationThread")
+ self.install_thread.cancel()
+ self.install_thread.wait(3000) # Wait up to 3 seconds
+ if self.install_thread.isRunning():
+ self.install_thread.terminate()
+
+ # Clean up other threads
+ threads = [
+ 'prefix_thread', 'config_thread', 'fetch_thread'
+ ]
+ for thread_name in threads:
+ if hasattr(self, thread_name):
+ thread = getattr(self, thread_name)
+ if thread and thread.isRunning():
+ debug_print(f"DEBUG: Terminating {thread_name}")
+ thread.terminate()
+ thread.wait(1000) # Wait up to 1 second
+
+ def _perform_modlist_integration(self):
+ """Integrate TTW into the modlist automatically
+
+ This is called when in integration mode. It will:
+ 1. Copy TTW output to modlist's mods folder
+ 2. Update modlist.txt for all profiles
+ 3. Update plugins.txt with TTW ESMs in correct order
+ 4. Emit integration_complete signal
+ """
+ try:
+ from pathlib import Path
+ import re
+ from PySide6.QtCore import QThread, Signal
+
+ # Get TTW output directory
+ ttw_output_dir = Path(self.install_dir_edit.text())
+ if not ttw_output_dir.exists():
+ error_msg = f"TTW output directory not found: {ttw_output_dir}"
+ self._safe_append_text(f"\nError: {error_msg}")
+ self.integration_complete.emit(False, "")
+ return
+
+ # Extract version from .mpi filename
+ mpi_path = self.file_edit.text().strip()
+ ttw_version = ""
+ if mpi_path:
+ mpi_filename = Path(mpi_path).stem
+ version_match = re.search(r'v?(\d+\.\d+(?:\.\d+)?)', mpi_filename, re.IGNORECASE)
+ if version_match:
+ ttw_version = version_match.group(1)
+
+ # Create background thread for integration
+ class IntegrationThread(QThread):
+ finished = Signal(bool, str) # success, ttw_version
+ progress = Signal(str) # progress message
+
+ def __init__(self, ttw_output_path, modlist_install_dir, ttw_version):
+ super().__init__()
+ self.ttw_output_path = ttw_output_path
+ self.modlist_install_dir = modlist_install_dir
+ self.ttw_version = ttw_version
+
+ def run(self):
+ try:
+ from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler
+
+ self.progress.emit("Integrating TTW into modlist...")
+ success = HoolamikeHandler.integrate_ttw_into_modlist(
+ ttw_output_path=self.ttw_output_path,
+ modlist_install_dir=self.modlist_install_dir,
+ ttw_version=self.ttw_version
+ )
+ self.finished.emit(success, self.ttw_version)
+ except Exception as e:
+ debug_print(f"ERROR: Integration thread failed: {e}")
+ import traceback
+ traceback.print_exc()
+ self.finished.emit(False, self.ttw_version)
+
+ # Show progress message
+ self._safe_append_text("\nIntegrating TTW into modlist (this may take a few minutes)...")
+
+ # Update status banner (only in integration mode - visible when collapsed)
+ if self._integration_mode:
+ self.status_banner.setText("Integrating TTW into modlist (this may take a few minutes)...")
+ self.status_banner.setStyleSheet(f"""
+ QLabel {{
+ background-color: #FFA500;
+ color: white;
+ font-weight: bold;
+ padding: 8px;
+ border-radius: 5px;
+ }}
+ """)
+
+ # Create and start integration thread
+ self.integration_thread = IntegrationThread(
+ ttw_output_dir,
+ Path(self._integration_install_dir),
+ ttw_version
+ )
+ self.integration_thread.progress.connect(self._safe_append_text)
+ self.integration_thread.finished.connect(self._on_integration_thread_finished)
+ self.integration_thread.start()
+
+ except Exception as e:
+ error_msg = f"Integration error: {str(e)}"
+ self._safe_append_text(f"\nError: {error_msg}")
+ debug_print(f"ERROR: {error_msg}")
+ import traceback
+ traceback.print_exc()
+ self.integration_complete.emit(False, "")
+
+ def _on_integration_thread_finished(self, success: bool, ttw_version: str):
+ """Handle completion of integration thread"""
+ try:
+ if success:
+ self._safe_append_text("\nTTW integration completed successfully!")
+
+ # Update status banner (only in integration mode)
+ if self._integration_mode:
+ self.status_banner.setText("TTW integration completed successfully!")
+ self.status_banner.setStyleSheet(f"""
+ QLabel {{
+ background-color: #28a745;
+ color: white;
+ font-weight: bold;
+ padding: 8px;
+ border-radius: 5px;
+ }}
+ """)
+
+ MessageService.information(
+ self, "Integration Complete",
+ f"TTW {ttw_version} has been successfully integrated into {self._integration_modlist_name}!",
+ safety_level="medium"
+ )
+ self.integration_complete.emit(True, ttw_version)
+ else:
+ self._safe_append_text("\nTTW integration failed!")
+
+ # Update status banner (only in integration mode)
+ if self._integration_mode:
+ self.status_banner.setText("TTW integration failed!")
+ self.status_banner.setStyleSheet(f"""
+ QLabel {{
+ background-color: #dc3545;
+ color: white;
+ font-weight: bold;
+ padding: 8px;
+ border-radius: 5px;
+ }}
+ """)
+
+ MessageService.critical(
+ self, "Integration Failed",
+ "Failed to integrate TTW into the modlist. Check the log for details."
+ )
+ self.integration_complete.emit(False, ttw_version)
+ except Exception as e:
+ debug_print(f"ERROR: Failed to handle integration completion: {e}")
+ self.integration_complete.emit(False, ttw_version)
+
+ def _create_ttw_mod_archive(self, automated=False):
+ """Create a zipped mod archive of TTW output for MO2 installation.
+
+ Args:
+ automated: If True, runs silently without user prompts (for automation)
+ """
+ try:
+ from pathlib import Path
+ import shutil
+ import re
+
+ output_dir = Path(self.install_dir_edit.text())
+ if not output_dir.exists():
+ if not automated:
+ MessageService.warning(self, "Output Directory Not Found",
+ f"Output directory does not exist:\n{output_dir}")
+ return False
+
+ # Extract version from .mpi filename (e.g., "TTW v3.4.mpi" -> "3.4")
+ mpi_path = self.file_edit.text().strip()
+ version_suffix = ""
+ if mpi_path:
+ mpi_filename = Path(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 = output_dir.parent / archive_name
+
+ if not automated:
+ self._safe_append_text(f"\nCreating mod archive: {archive_name}.zip")
+ self._safe_append_text("This may take several minutes...")
+
+ # 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(output_dir) # directory to archive
+ )
+
+ if not automated:
+ self._safe_append_text(f"\nArchive created successfully: {Path(final_archive).name}")
+ MessageService.information(
+ self, "Archive Created",
+ f"TTW mod archive created successfully!\n\n"
+ f"Location: {final_archive}\n\n"
+ f"You can now install this archive as a mod in MO2.",
+ safety_level="medium"
+ )
+
+ return True
+
+ except Exception as e:
+ error_msg = f"Failed to create mod archive: {str(e)}"
+ if not automated:
+ self._safe_append_text(f"\nError: {error_msg}")
+ MessageService.critical(self, "Archive Creation Failed", error_msg)
+ return False
+
+ def cancel_installation(self):
+ """Cancel the currently running installation"""
+ reply = MessageService.question(
+ self, "Cancel Installation",
+ "Are you sure you want to cancel the installation?",
+ critical=False # Non-critical, won't steal focus
+ )
+
+ if reply == QMessageBox.Yes:
+ self._safe_append_text("\nCancelling installation...")
+
+ # Stop the elapsed timer if running
+ if hasattr(self, 'ttw_elapsed_timer') and self.ttw_elapsed_timer.isActive():
+ self.ttw_elapsed_timer.stop()
+
+ # Update status banner
+ if hasattr(self, 'status_banner'):
+ self.status_banner.setText("Installation cancelled by user")
+ self.status_banner.setStyleSheet(f"""
+ background-color: #4d3d1a;
+ color: #FFA500;
+ padding: 8px;
+ border-radius: 4px;
+ font-weight: bold;
+ font-size: 13px;
+ """)
+
+ # Cancel the installation thread if it exists
+ if hasattr(self, 'install_thread') and self.install_thread.isRunning():
+ self.install_thread.cancel()
+ self.install_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown
+ if self.install_thread.isRunning():
+ self.install_thread.terminate() # Force terminate if needed
+ self.install_thread.wait(1000)
+
+ # Cancel the automated prefix thread if it exists
+ if hasattr(self, 'prefix_thread') and self.prefix_thread.isRunning():
+ self.prefix_thread.terminate()
+ self.prefix_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown
+ if self.prefix_thread.isRunning():
+ self.prefix_thread.terminate() # Force terminate if needed
+ self.prefix_thread.wait(1000)
+
+ # Cancel the configuration thread if it exists
+ if hasattr(self, 'config_thread') and self.config_thread.isRunning():
+ self.config_thread.terminate()
+ self.config_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown
+ if self.config_thread.isRunning():
+ self.config_thread.terminate() # Force terminate if needed
+ self.config_thread.wait(1000)
+
+ # Cleanup any remaining processes
+ self.cleanup_processes()
+
+ # Reset button states and re-enable all controls
+ self._enable_controls_after_operation()
+ self.cancel_btn.setVisible(True)
+ self.cancel_install_btn.setVisible(False)
+
+ self._safe_append_text("Installation cancelled by user.")
+
+ def _show_somnium_post_install_guidance(self):
+ """Show guidance popup for Somnium post-installation steps"""
+ from ..widgets.message_service import MessageService
+
+ guidance_text = f"""Somnium Post-Installation Required
+Due to Somnium's non-standard folder structure, you need to manually update the binary paths in ModOrganizer:
+1. Launch the Steam shortcut created for Somnium
+2. In ModOrganizer, go to Settings → Executables
+3. For each executable entry (SKSE64, etc.), update the binary path to point to:
+{self._somnium_install_dir}/files/root/Enderal Special Edition/skse64_loader.exe
+Note: Full Somnium support will be added in a future Jackify update.
+You can also refer to the Somnium installation guide at:
+https://wiki.scenicroute.games/Somnium/1_Installation.html"""
+
+ MessageService.information(self, "Somnium Setup Required", guidance_text)
+
+ # Reset the guidance flag
+ self._show_somnium_guidance = False
+ self._somnium_install_dir = None
+
+ def cancel_and_cleanup(self):
+ """Handle Cancel button - clean up processes and go back"""
+ self.cleanup_processes()
+ # Restore main window to standard Jackify size before leaving
+ try:
+ from PySide6.QtCore import Qt as _Qt
+ main_window = self.window()
+
+ # Check if we're on Steam Deck - if so, skip all window size modifications
+ is_steamdeck = False
+ if self.system_info and getattr(self.system_info, 'is_steamdeck', False):
+ is_steamdeck = True
+ elif not self.system_info and main_window and hasattr(main_window, 'system_info'):
+ is_steamdeck = getattr(main_window.system_info, 'is_steamdeck', False)
+
+ if main_window and not is_steamdeck:
+ # Desktop: Restore main window to standard Jackify size
+ main_window.setMaximumHeight(16777215)
+ main_window.setMinimumHeight(900)
+ # Prefer a sane default height; keep current width
+ current_width = max(1200, main_window.size().width())
+ main_window.resize(current_width, 900)
+ elif is_steamdeck:
+ # Steam Deck: Only clear any constraints that might exist, don't set new ones
+ # This prevents window size issues when navigating away
+ debug_print("DEBUG: Steam Deck detected in cancel_and_cleanup, skipping window resize")
+ if main_window:
+ # Clear any size constraints that might have been set
+ from PySide6.QtCore import QSize
+ main_window.setMaximumSize(QSize(16777215, 16777215))
+ main_window.setMinimumSize(QSize(0, 0))
+
+ # Ensure we exit in collapsed state so next entry starts compact (both Desktop and Deck)
+ if self.show_details_checkbox.isChecked():
+ self.show_details_checkbox.blockSignals(True)
+ self.show_details_checkbox.setChecked(False)
+ self.show_details_checkbox.blockSignals(False)
+ # Only toggle console visibility on Desktop (on Deck it's always visible)
+ if not is_steamdeck:
+ self._toggle_console_visibility(_Qt.Unchecked)
+ except Exception:
+ pass
+ self.go_back()
+
+ def reset_screen_to_defaults(self):
+ """Reset the screen to default state when navigating back from main menu"""
+ # Reset form fields
+ self.file_edit.setText("")
+ self.install_dir_edit.setText(self.config_handler.get_modlist_install_base_dir())
+
+ # Clear console and process monitor
+ self.console.clear()
+ self.process_monitor.clear()
+
+ # Re-enable controls (in case they were disabled from previous errors)
+ self._enable_controls_after_operation()
+
+ # Check requirements when screen is actually shown (not on app startup)
+ self.check_requirements()
+
+
\ No newline at end of file
diff --git a/jackify/frontends/gui/screens/main_menu.py b/jackify/frontends/gui/screens/main_menu.py
index 28ab897..35a59bf 100644
--- a/jackify/frontends/gui/screens/main_menu.py
+++ b/jackify/frontends/gui/screens/main_menu.py
@@ -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
\ No newline at end of file
+ self.stacked_widget.setCurrentIndex(1) # Default to placeholder
\ No newline at end of file
diff --git a/jackify/frontends/gui/screens/modlist_tasks.py b/jackify/frontends/gui/screens/modlist_tasks.py
index 4a6eb94..9a432a3 100644
--- a/jackify/frontends/gui/screens/modlist_tasks.py
+++ b/jackify/frontends/gui/screens/modlist_tasks.py
@@ -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"""
diff --git a/jackify/frontends/gui/services/message_service.py b/jackify/frontends/gui/services/message_service.py
index 2b56945..3dbfe41 100644
--- a/jackify/frontends/gui/services/message_service.py
+++ b/jackify/frontends/gui/services/message_service.py
@@ -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)
diff --git a/jackify/frontends/gui/utils.py b/jackify/frontends/gui/utils.py
index 80999cf..b35a830 100644
--- a/jackify/frontends/gui/utils.py
+++ b/jackify/frontends/gui/utils.py
@@ -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 = ''