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 = ''