import logging import os import subprocess import zipfile import tarfile from pathlib import Path import yaml # Assuming PyYAML is installed from typing import Dict, Optional, List import requests # Import necessary handlers from the current Jackify structure from .path_handler import PathHandler from .vdf_handler import VDFHandler # Keeping just in case 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 from .logging_handler import LoggingHandler from .status_utils import show_status, clear_status from .subprocess_utils import get_clean_subprocess_env logger = logging.getLogger(__name__) # Define default Hoolamike AppIDs for relevant games TARGET_GAME_APPIDS = { 'Fallout 3': '22370', # GOTY Edition 'Fallout New Vegas': '22380', # Base game 'Skyrim Special Edition': '489830', 'Oblivion': '22330', # GOTY Edition 'Fallout 4': '377160' } # Define the expected name of the native Hoolamike executable HOOLAMIKE_EXECUTABLE_NAME = "hoolamike" # Assuming this is the binary name # Keep consistent with logs directory - use ~/Jackify/ for user-visible managed components JACKIFY_BASE_DIR = Path.home() / "Jackify" # Use Jackify base directory for ALL Hoolamike-related files to centralize management DEFAULT_HOOLAMIKE_APP_INSTALL_DIR = JACKIFY_BASE_DIR / "Hoolamike" HOOLAMIKE_CONFIG_DIR = DEFAULT_HOOLAMIKE_APP_INSTALL_DIR HOOLAMIKE_CONFIG_FILENAME = "hoolamike.yaml" # Default dirs for other components DEFAULT_HOOLAMIKE_DOWNLOADS_DIR = JACKIFY_BASE_DIR / "Mod_Downloads" DEFAULT_MODLIST_INSTALL_BASE_DIR = Path.home() / "ModdedGames" class HoolamikeHandler: """Handles discovery, configuration, and execution of Hoolamike tasks. Assumes Hoolamike is a native Linux CLI application. """ def __init__(self, steamdeck: bool, verbose: bool, filesystem_handler: FileSystemHandler, config_handler: ConfigHandler, menu_handler=None): """Initialize the handler and perform initial discovery.""" self.steamdeck = steamdeck self.verbose = verbose self.path_handler = PathHandler() self.filesystem_handler = filesystem_handler self.config_handler = config_handler self.menu_handler = menu_handler # 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] = {} # Allow user override for Hoolamike app install path later self.hoolamike_app_install_path: Path = DEFAULT_HOOLAMIKE_APP_INSTALL_DIR self.hoolamike_executable_path: Optional[Path] = None # Path to the binary self.hoolamike_installed: bool = False self.hoolamike_config_path: Path = HOOLAMIKE_CONFIG_DIR / HOOLAMIKE_CONFIG_FILENAME self.hoolamike_config: Optional[Dict] = None # Load Hoolamike install path from Jackify config if it exists saved_path_str = self.config_handler.get('hoolamike_install_path') if saved_path_str and Path(saved_path_str).is_dir(): # Basic check if path exists self.hoolamike_app_install_path = Path(saved_path_str) self.logger.info(f"Loaded Hoolamike install path from Jackify config: {self.hoolamike_app_install_path}") self._load_hoolamike_config() self._run_discovery() def _ensure_hoolamike_dirs_exist(self): """Ensure base directories for Hoolamike exist.""" try: HOOLAMIKE_CONFIG_DIR.mkdir(parents=True, exist_ok=True) # Separate Hoolamike config self.hoolamike_app_install_path.mkdir(parents=True, exist_ok=True) # Install dir (~/Jackify/Hoolamike) # Default downloads dir also needs to exist if we reference it DEFAULT_HOOLAMIKE_DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True) except OSError as e: self.logger.error(f"Error creating Hoolamike directories: {e}", exc_info=True) # Decide how to handle this - maybe raise an exception? def _check_hoolamike_installation(self): """Check if Hoolamike executable exists at the expected location. Prioritizes path stored in config if available. """ potential_exe_path = self.hoolamike_app_install_path / HOOLAMIKE_EXECUTABLE_NAME check_path = None if potential_exe_path.is_file() and os.access(potential_exe_path, os.X_OK): check_path = potential_exe_path self.logger.info(f"Found Hoolamike at current path: {check_path}") else: self.logger.info(f"Hoolamike executable ({HOOLAMIKE_EXECUTABLE_NAME}) not found or not executable at current path {self.hoolamike_app_install_path}.") # Update state based on whether we found a valid path if check_path: self.hoolamike_installed = True self.hoolamike_executable_path = check_path else: self.hoolamike_installed = False self.hoolamike_executable_path = None def _generate_default_config(self) -> Dict: """Generates the default configuration dictionary.""" self.logger.info("Generating default Hoolamike config structure.") # Detection is now handled separately after loading config detected_paths = self.path_handler.find_game_install_paths(TARGET_GAME_APPIDS) config = { "downloaders": { "downloads_directory": str(DEFAULT_HOOLAMIKE_DOWNLOADS_DIR), "nexus": {"api_key": "YOUR_API_KEY_HERE"} }, "installation": { "wabbajack_file_path": "", # Placeholder, set per-run "installation_path": "" # Placeholder, set per-run }, "games": { # Only include detected games with consistent formatting (no spaces) self._format_game_name(game_name): {"root_directory": str(path)} for game_name, path in detected_paths.items() }, "fixup": { "game_resolution": "1920x1080" }, "extras": { "tale_of_two_wastelands": { "path_to_ttw_mpi_file": "", # Placeholder "variables": { "DESTINATION": "" # Placeholder } } } } # Add comment if no games detected if not detected_paths: # This won't appear in YAML, logic adjusted below pass return config def _format_game_name(self, game_name: str) -> str: """Formats game name for Hoolamike configuration (removes spaces). Hoolamike expects game names without spaces like: Fallout3, FalloutNewVegas, SkyrimSpecialEdition """ # Handle specific game name formats that Hoolamike expects game_name_map = { "Fallout 3": "Fallout3", "Fallout New Vegas": "FalloutNewVegas", "Skyrim Special Edition": "SkyrimSpecialEdition", "Fallout 4": "Fallout4", "Oblivion": "Oblivion" # No change needed } # Use predefined mapping if available if game_name in game_name_map: return game_name_map[game_name] # Otherwise, just remove spaces as fallback return game_name.replace(" ", "") def _load_hoolamike_config(self): """Load hoolamike.yaml if it exists, or generate a default one.""" self._ensure_hoolamike_dirs_exist() # Ensure parent dir exists if self.hoolamike_config_path.is_file(): self.logger.info(f"Found existing hoolamike.yaml at {self.hoolamike_config_path}. Loading...") try: with open(self.hoolamike_config_path, 'r', encoding='utf-8') as f: self.hoolamike_config = yaml.safe_load(f) if not isinstance(self.hoolamike_config, dict): self.logger.warning(f"Failed to parse hoolamike.yaml as a dictionary. Generating default.") self.hoolamike_config = self._generate_default_config() self.save_hoolamike_config() # Save the newly generated default else: self.logger.info("Successfully loaded hoolamike.yaml configuration.") # Game path merging is handled in _run_discovery now except yaml.YAMLError as e: self.logger.error(f"Error parsing hoolamike.yaml: {e}. The file may be corrupted.") # Don't automatically overwrite - let user decide self.hoolamike_config = None return False except Exception as e: self.logger.error(f"Error reading hoolamike.yaml: {e}.", exc_info=True) # Don't automatically overwrite - let user decide self.hoolamike_config = None return False else: self.logger.info(f"hoolamike.yaml not found at {self.hoolamike_config_path}. Generating default configuration.") self.hoolamike_config = self._generate_default_config() self.save_hoolamike_config() return True def save_hoolamike_config(self): """Saves the current configuration dictionary to hoolamike.yaml.""" if self.hoolamike_config is None: self.logger.error("Cannot save config, internal config dictionary is None.") return False self._ensure_hoolamike_dirs_exist() # Ensure parent dir exists self.logger.info(f"Saving configuration to {self.hoolamike_config_path}") try: with open(self.hoolamike_config_path, 'w', encoding='utf-8') as f: # Add comments conditionally f.write("# Configuration file created or updated by Jackify\n") 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, width=float('inf')) self.logger.info("Configuration saved successfully.") return True except Exception as e: self.logger.error(f"Error saving hoolamike.yaml: {e}", exc_info=True) return False def _run_discovery(self): """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): """Detect game install paths and update state and config.""" self.logger.info("Detecting game install paths...") # Always run detection detected_paths = self.path_handler.find_game_install_paths(TARGET_GAME_APPIDS) self.game_install_paths = detected_paths # Update internal state self.logger.info(f"Detected game paths: {detected_paths}") # Update the loaded config if it exists if self.hoolamike_config is not None: 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 --- # 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. Returns: bool: True if installation/update was successful or process was properly cancelled, False if a critical error occurred. """ self.logger.info("Starting Hoolamike Installation/Update...") print("\nStarting Hoolamike Installation/Update...") # 1. Prompt user to install/reinstall/update try: # Check if Hoolamike is already installed at the expected path self._check_hoolamike_installation() if self.hoolamike_installed: self.logger.info(f"Hoolamike appears to be installed at: {self.hoolamike_executable_path}") print(f"{COLOR_INFO}Hoolamike is already installed at:{COLOR_RESET}") print(f" {self.hoolamike_executable_path}") # Use a menu-style prompt for reinstall/update print(f"\n{COLOR_PROMPT}Choose an action for Hoolamike:{COLOR_RESET}") print(f" 1. Reinstall/Update Hoolamike") print(f" 2. Keep existing installation (return to menu)") while True: choice = input(f"Select an option [1-2]: ").strip() if choice == '1': self.logger.info("User chose to reinstall/update Hoolamike.") break elif choice == '2' or choice.lower() == 'q': self.logger.info("User chose to keep existing Hoolamike installation.") print("Skipping Hoolamike installation/update.") return True else: print(f"{COLOR_WARNING}Invalid choice. Please enter 1 or 2.{COLOR_RESET}") # 2. Get installation directory from user (allow override) self.logger.info(f"Default install path: {self.hoolamike_app_install_path}") print("\nHoolamike Installation Directory:") print(f"Default: {self.hoolamike_app_install_path}") install_dir = self.menu_handler.get_directory_path( prompt_message=f"Specify where to install Hoolamike (or press Enter for default)", default_path=self.hoolamike_app_install_path, create_if_missing=True, no_header=True ) if install_dir is None: self.logger.warning("User cancelled Hoolamike installation path selection.") print("Installation cancelled.") return True # Check if hoolamike already exists at this specific path potential_existing_exe = install_dir / HOOLAMIKE_EXECUTABLE_NAME if potential_existing_exe.is_file() and os.access(potential_existing_exe, os.X_OK): self.logger.info(f"Hoolamike executable found at the chosen path: {potential_existing_exe}") print(f"{COLOR_INFO}Hoolamike appears to already be installed at:{COLOR_RESET}") print(f" {install_dir}") # Use menu-style prompt for overwrite print(f"{COLOR_PROMPT}Choose an action for the existing installation:{COLOR_RESET}") print(f" 1. Download and overwrite (update)") print(f" 2. Keep existing installation (return to menu)") while True: overwrite_choice = input(f"Select an option [1-2]: ").strip() if overwrite_choice == '1': self.logger.info("User chose to update (overwrite) existing Hoolamike installation.") break elif overwrite_choice == '2' or overwrite_choice.lower() == 'q': self.logger.info("User chose to keep existing Hoolamike installation at chosen path.") print("Update cancelled. Using existing installation for this session.") self.hoolamike_app_install_path = install_dir self.hoolamike_executable_path = potential_existing_exe self.hoolamike_installed = True return True else: print(f"{COLOR_WARNING}Invalid choice. Please enter 1 or 2.{COLOR_RESET}") # Proceed with install/update self.logger.info(f"Proceeding with installation to directory: {install_dir}") self.hoolamike_app_install_path = install_dir # Get latest release info from GitHub release_url = "https://api.github.com/repos/Niedzwiedzw/hoolamike/releases/latest" download_url = None asset_name = None try: self.logger.info(f"Fetching latest release info from {release_url}") show_status("Fetching latest Hoolamike release info...") response = requests.get(release_url, timeout=15, verify=True) response.raise_for_status() release_data = response.json() self.logger.debug(f"GitHub Release Data: {release_data}") linux_tar_asset = None linux_zip_asset = None for asset in release_data.get('assets', []): name = asset.get('name', '').lower() self.logger.debug(f"Checking asset: {name}") is_linux = 'linux' in name is_x64 = 'x86_64' in name or 'amd64' in name is_incompatible_arch = 'arm' in name or 'aarch64' in name or 'darwin' in name if is_linux and is_x64 and not is_incompatible_arch: if name.endswith(('.tar.gz', '.tgz')): linux_tar_asset = asset self.logger.debug(f"Found potential tar asset: {name}") break elif name.endswith('.zip') and not linux_tar_asset: linux_zip_asset = asset self.logger.debug(f"Found potential zip asset: {name}") chosen_asset = linux_tar_asset or linux_zip_asset if not chosen_asset: clear_status() self.logger.error("Could not find a suitable Linux x86_64 download asset (tar.gz/zip) in the latest release.") print(f"{COLOR_ERROR}Error: Could not find a linux x86_64 download asset in the latest Hoolamike release.{COLOR_RESET}") return False download_url = chosen_asset.get('browser_download_url') asset_name = chosen_asset.get('name') if not download_url or not asset_name: clear_status() self.logger.error(f"Chosen asset is missing URL or name: {chosen_asset}") print(f"{COLOR_ERROR}Error: Found asset but could not get download details.{COLOR_RESET}") return False self.logger.info(f"Found asset '{asset_name}' for download: {download_url}") clear_status() except requests.exceptions.RequestException as e: clear_status() self.logger.error(f"Failed to fetch release info from GitHub: {e}") print(f"Error: Failed to contact GitHub to check for Hoolamike updates: {e}") return False except Exception as e: clear_status() self.logger.error(f"Error parsing release info: {e}", exc_info=True) print("Error: Failed to understand release information from GitHub.") return False # Download the asset show_status(f"Downloading {asset_name}...") temp_download_path = self.hoolamike_app_install_path / asset_name if not self.filesystem_handler.download_file(download_url, temp_download_path, overwrite=True, quiet=True): clear_status() self.logger.error(f"Failed to download {asset_name} from {download_url}") print(f"{COLOR_ERROR}Error: Failed to download Hoolamike asset.{COLOR_RESET}") return False clear_status() self.logger.info(f"Downloaded {asset_name} successfully to {temp_download_path}") show_status("Extracting Hoolamike archive...") # Extract the asset try: if asset_name.lower().endswith(('.tar.gz', '.tgz')): self.logger.debug(f"Extracting tar file: {temp_download_path}") with tarfile.open(temp_download_path, 'r:*') as tar: tar.extractall(path=self.hoolamike_app_install_path) self.logger.info("Extracted tar file successfully.") elif asset_name.lower().endswith('.zip'): self.logger.debug(f"Extracting zip file: {temp_download_path}") with zipfile.ZipFile(temp_download_path, 'r') as zip_ref: zip_ref.extractall(self.hoolamike_app_install_path) self.logger.info("Extracted zip file successfully.") else: clear_status() self.logger.error(f"Unknown archive format for asset: {asset_name}") print(f"{COLOR_ERROR}Error: Unknown file type '{asset_name}'. Cannot extract.{COLOR_RESET}") return False clear_status() print("Extraction complete. Setting permissions...") except (tarfile.TarError, zipfile.BadZipFile, EOFError) as e: clear_status() self.logger.error(f"Failed to extract archive {temp_download_path}: {e}", exc_info=True) print(f"{COLOR_ERROR}Error: Failed to extract downloaded file: {e}{COLOR_RESET}") return False except Exception as e: clear_status() self.logger.error(f"An unexpected error occurred during extraction: {e}", exc_info=True) print(f"{COLOR_ERROR}An unexpected error occurred during extraction.{COLOR_RESET}") return False finally: # Clean up downloaded archive if temp_download_path.exists(): try: temp_download_path.unlink() self.logger.debug(f"Removed temporary download file: {temp_download_path}") except OSError as e: self.logger.warning(f"Could not remove temporary download file {temp_download_path}: {e}") # Set execute permissions on the binary executable_path = self.hoolamike_app_install_path / HOOLAMIKE_EXECUTABLE_NAME if executable_path.is_file(): try: show_status("Setting permissions on Hoolamike executable...") os.chmod(executable_path, 0o755) self.logger.info(f"Set execute permissions (+x) on {executable_path}") clear_status() print("Permissions set successfully.") except OSError as e: clear_status() self.logger.error(f"Failed to set execute permission on {executable_path}: {e}") print(f"{COLOR_ERROR}Error: Could not set execute permission on Hoolamike executable.{COLOR_RESET}") else: clear_status() self.logger.error(f"Hoolamike executable not found after extraction at {executable_path}") print(f"{COLOR_ERROR}Error: Hoolamike executable missing after extraction!{COLOR_RESET}") return False # Update self.hoolamike_installed and self.hoolamike_executable_path state self.logger.info("Refreshing Hoolamike installation status...") self._check_hoolamike_installation() if not self.hoolamike_installed: self.logger.error("Hoolamike check failed after apparent successful install/extract.") print(f"{COLOR_ERROR}Error: Installation completed, but failed final verification check.{COLOR_RESET}") return False # Save install path to Jackify config self.logger.info(f"Saving Hoolamike install path to Jackify config: {self.hoolamike_app_install_path}") self.config_handler.set('hoolamike_install_path', str(self.hoolamike_app_install_path)) if not self.config_handler.save_config(): self.logger.warning("Failed to save Jackify config file after updating Hoolamike path.") # Non-fatal, but warn user? print(f"{COLOR_WARNING}Warning: Could not save installation path to main Jackify config file.{COLOR_RESET}") print(f"{COLOR_SUCCESS}Hoolamike installation/update successful!{COLOR_RESET}") self.logger.info("Hoolamike install/update process completed successfully.") return True except Exception as e: self.logger.error(f"Error during Hoolamike installation/update: {e}", exc_info=True) print(f"{COLOR_ERROR}Error: An unexpected error occurred during Hoolamike installation/update: {e}{COLOR_RESET}") return False def install_modlist(self, wabbajack_path=None, install_path=None, downloads_path=None, premium=False, api_key=None, game_resolution=None, context=None): """ Install a Wabbajack modlist using Hoolamike, following Jackify's Discovery/Configuration/Confirmation pattern. """ self.logger.info("Starting Hoolamike modlist install (Discovery Phase)") self._check_hoolamike_installation() menu = self.menu_handler print(f"\n{'='*60}") print(f"{COLOR_INFO}Hoolamike Modlist Installation{COLOR_RESET}") print(f"{'='*60}\n") # --- Discovery Phase --- # 1. Auto-detect games (robust, multi-library) detected_games = self.path_handler.find_vanilla_game_paths() # 2. Prompt for .wabbajack file (custom prompt, only accept .wabbajack, q to exit, with tab-completion) print() while not wabbajack_path: print(f"{COLOR_WARNING}This option requires a Nexus Mods Premium account for automatic downloads.{COLOR_RESET}") print(f"If you don't have a premium account, please use the '{COLOR_SELECTION}Non-Premium Installation{COLOR_RESET}' option from the previous menu instead.\n") print(f"Before continuing, you'll need a .wabbajack file. You can usually find these at:") print(f" 1. {COLOR_INFO}https://build.wabbajack.org/authored_files{COLOR_RESET} - Official Wabbajack modlist repository") print(f" 2. {COLOR_INFO}https://www.nexusmods.com/{COLOR_RESET} - Some modlist authors publish on Nexus Mods") print(f" 3. Various Discord communities for specific modlists\n") print(f"{COLOR_WARNING}NOTE: Download the .wabbajack file first, then continue. Enter 'q' to exit.{COLOR_RESET}\n") # Use menu.get_existing_file_path for tab-completion candidate = menu.get_existing_file_path( prompt_message="Enter the path to your .wabbajack file (or 'q' to cancel):", extension_filter=".wabbajack", no_header=True ) if candidate is None: print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") return False # If user literally typed 'q', treat as cancel if str(candidate).strip().lower() == 'q': print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") return False wabbajack_path = candidate # 3. Prompt for install directory print() while True: install_path_result = menu.get_directory_path( prompt_message="Select the directory where the modlist should be installed:", default_path=DEFAULT_MODLIST_INSTALL_BASE_DIR / wabbajack_path.stem, create_if_missing=True, no_header=False ) if not install_path_result: print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") return False # Handle tuple (path, should_create) if isinstance(install_path_result, tuple): install_path, install_should_create = install_path_result else: install_path, install_should_create = install_path_result, False # Check if directory exists and is not empty if install_path.exists() and any(install_path.iterdir()): print(f"{COLOR_WARNING}Warning: The selected directory '{install_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}") continue break # 4. Prompt for downloads directory print() if not downloads_path: downloads_path_result = menu.get_directory_path( prompt_message="Select the directory for mod downloads:", default_path=DEFAULT_HOOLAMIKE_DOWNLOADS_DIR, create_if_missing=True, no_header=False ) if not downloads_path_result: print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") return False # Handle tuple (path, should_create) if isinstance(downloads_path_result, tuple): downloads_path, downloads_should_create = downloads_path_result else: downloads_path, downloads_should_create = downloads_path_result, False else: downloads_should_create = False # 5. Nexus API key print() current_api_key = self.hoolamike_config.get('downloaders', {}).get('nexus', {}).get('api_key') if self.hoolamike_config else None if not current_api_key or current_api_key == 'YOUR_API_KEY_HERE': api_key = menu.get_nexus_api_key(current_api_key) if not api_key: print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}") return False else: api_key = current_api_key # --- Summary & Confirmation --- print(f"\n{'-'*60}") print(f"{COLOR_INFO}Summary of configuration:{COLOR_RESET}") print(f"- Wabbajack file: {wabbajack_path}") print(f"- Install directory: {install_path}") print(f"- Downloads directory: {downloads_path}") print(f"- Nexus API key: [{'Set' if api_key else 'Not Set'}]") print("- Games:") for game in ["Fallout 3", "Fallout New Vegas", "Skyrim Special Edition", "Oblivion", "Fallout 4"]: 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 Hoolamike install? (Warning: 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 # --- Actually create directories if needed --- if install_should_create and not install_path.exists(): try: install_path.mkdir(parents=True, exist_ok=True) print(f"{COLOR_SUCCESS}Install directory created: {install_path}{COLOR_RESET}") except Exception as e: print(f"{COLOR_ERROR}Failed to create install directory: {e}{COLOR_RESET}") return False if downloads_should_create and not downloads_path.exists(): try: downloads_path.mkdir(parents=True, exist_ok=True) print(f"{COLOR_SUCCESS}Downloads directory created: {downloads_path}{COLOR_RESET}") except Exception as e: print(f"{COLOR_ERROR}Failed to create downloads directory: {e}{COLOR_RESET}") return False # --- Configuration Phase --- # Prepare config dict config = { "downloaders": { "downloads_directory": str(downloads_path), "nexus": {"api_key": api_key} }, "installation": { "wabbajack_file_path": str(wabbajack_path), "installation_path": str(install_path) }, "games": { self._format_game_name(game): {"root_directory": str(path)} for game, path in detected_games.items() }, "fixup": { "game_resolution": "1920x1080" }, # Resolution intentionally omitted # "extras": {}, # No 'jackify_managed' key here } self.hoolamike_config = config if not self.save_hoolamike_config(): print(f"{COLOR_ERROR}Failed to save hoolamike.yaml. Aborting.{COLOR_RESET}") return False # --- Run Hoolamike --- print(f"\n{COLOR_INFO}Starting Hoolamike...{COLOR_RESET}") print(f"{COLOR_INFO}Streaming output below. Press Ctrl+C to cancel and return to Jackify menu.{COLOR_RESET}\n") # Defensive: Ensure executable path is set and valid if not self.hoolamike_executable_path or not Path(self.hoolamike_executable_path).is_file(): print(f"{COLOR_ERROR}Error: Hoolamike executable not found or not set. Please (re)install Hoolamike from the menu before continuing.{COLOR_RESET}") input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}") return False try: cmd = [str(self.hoolamike_executable_path), "install"] ret = subprocess.call(cmd, cwd=str(self.hoolamike_app_install_path), env=get_clean_subprocess_env()) if ret == 0: print(f"\n{COLOR_SUCCESS}Hoolamike completed successfully!{COLOR_RESET}") input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}") return True else: print(f"\n{COLOR_ERROR}Hoolamike process failed with exit code {ret}.{COLOR_RESET}") input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}") return False except KeyboardInterrupt: print(f"\n{COLOR_WARNING}Hoolamike install interrupted by user. Returning to menu.{COLOR_RESET}") input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}") return False except Exception as e: print(f"\n{COLOR_ERROR}Error running Hoolamike: {e}{COLOR_RESET}") input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}") return False 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 (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 """ menu = self.menu_handler print(f"\n{'='*60}") print(f"{COLOR_INFO}Hoolamike: Tale of Two Wastelands Installation{COLOR_RESET}") print(f"{'='*60}\n") print(f"This feature will install Tale of Two Wastelands (TTW) using Hoolamike.") print(f"Requirements:") print(f" • Fallout 3 and Fallout New Vegas must be installed and detected.") 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") # 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 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 # Call the clean backend function success, message = self.install_ttw_backend(ttw_mpi_path, ttw_output_path) if success: print(f"\n{COLOR_SUCCESS}{message}{COLOR_RESET}") # 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() 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: 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 def _update_hoolamike_config_for_ttw(self, ttw_mpi_path: Path, ttw_output_path: Path): """Update the Hoolamike configuration with settings for TTW installation.""" # Ensure extras and TTW sections exist if "extras" not in self.hoolamike_config: self.hoolamike_config["extras"] = {} if "tale_of_two_wastelands" not in self.hoolamike_config["extras"]: self.hoolamike_config["extras"]["tale_of_two_wastelands"] = { "variables": {} } # Update TTW configuration ttw_config = self.hoolamike_config["extras"]["tale_of_two_wastelands"] ttw_config["path_to_ttw_mpi_file"] = str(ttw_mpi_path) # Ensure variables section exists if "variables" not in ttw_config: ttw_config["variables"] = {} # Set destination variable ttw_config["variables"]["DESTINATION"] = str(ttw_output_path) # 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 using proper Hoolamike naming format for game in ['Fallout 3', 'Fallout New Vegas']: if game in self.game_install_paths: # 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 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(): # Create a backup with timestamp import datetime timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") backup_path = self.hoolamike_config_path.with_suffix(f".{timestamp}.bak") try: import shutil shutil.copy2(self.hoolamike_config_path, backup_path) self.logger.info(f"Created backup of existing config at {backup_path}") print(f"{COLOR_INFO}Created backup of existing config at {backup_path}{COLOR_RESET}") except Exception as e: self.logger.error(f"Failed to create backup of config: {e}") print(f"{COLOR_WARNING}Warning: Failed to create backup of config: {e}{COLOR_RESET}") # Generate and save a fresh default config self.logger.info("Generating new default configuration") self.hoolamike_config = self._generate_default_config() if self.save_hoolamike_config(): self.logger.info("Successfully reset config to defaults") print(f"{COLOR_SUCCESS}Successfully reset configuration to defaults.{COLOR_RESET}") return True else: self.logger.error("Failed to save new default config") print(f"{COLOR_ERROR}Failed to save new default configuration.{COLOR_RESET}") return False def edit_hoolamike_config(self): """Opens the hoolamike.yaml file in a chosen editor, with a 0 option to return to menu.""" self.logger.info("Task: Edit Hoolamike Config started...") self._check_hoolamike_installation() if not self.hoolamike_installed: self.logger.warning("Cannot edit config - Hoolamike not installed") print(f"\n{COLOR_WARNING}Hoolamike is not installed through Jackify yet.{COLOR_RESET}") print(f"Please use option 1 from the Hoolamike menu to install Hoolamike first.") print(f"This will ensure that Jackify can properly manage the Hoolamike configuration.") return False if self.hoolamike_config is None: self.logger.warning("Config is not loaded properly. Will attempt to fix or create.") print(f"\n{COLOR_WARNING}Configuration file may be corrupted or not accessible.{COLOR_RESET}") print("Options:") print("1. Reset to default configuration (backup will be created)") print("2. Try to edit the file anyway (may be corrupted)") print("0. Cancel and return to menu") choice = input("\nEnter your choice (0-2): ").strip() if choice == "1": if not self.reset_config(): self.logger.error("Failed to reset configuration") print(f"{COLOR_ERROR}Failed to reset configuration. See logs for details.{COLOR_RESET}") return elif choice == "2": self.logger.warning("User chose to edit potentially corrupted config") # Continue to editing elif choice == "0": self.logger.info("User cancelled editing corrupted config") print("Edit cancelled.") return else: self.logger.info("User cancelled editing corrupted config") print("Edit cancelled.") return if not self.hoolamike_config_path.exists(): self.logger.warning(f"Hoolamike config file does not exist at {self.hoolamike_config_path}. Generating default before editing.") self.hoolamike_config = self._generate_default_config() self.save_hoolamike_config() if not self.hoolamike_config_path.exists(): self.logger.error("Failed to create config file for editing.") print("Error: Could not create configuration file.") return available_editors = ["nano", "vim", "vi", "gedit", "kate", "micro"] preferred_editor = os.environ.get("EDITOR") found_editors = {} import shutil for editor_name in available_editors: editor_path = shutil.which(editor_name) if editor_path and editor_path not in found_editors.values(): found_editors[editor_name] = editor_path if preferred_editor: preferred_editor_path = shutil.which(preferred_editor) if preferred_editor_path and preferred_editor_path not in found_editors.values(): display_name = os.path.basename(preferred_editor) if '/' in preferred_editor else preferred_editor if display_name not in found_editors: found_editors[display_name] = preferred_editor_path if not found_editors: self.logger.error("No suitable text editors found on the system.") print(f"{COLOR_ERROR}Error: No common text editors (nano, vim, gedit, kate, micro) found.{COLOR_RESET}") return sorted_editor_names = sorted(found_editors.keys()) print("\nSelect an editor to open the configuration file:") print(f"(System default EDITOR is: {preferred_editor if preferred_editor else 'Not set'})") for i, name in enumerate(sorted_editor_names): print(f" {i + 1}. {name}") print(f" 0. Return to Hoolamike Menu") while True: try: choice = input(f"Enter choice (0-{len(sorted_editor_names)}): ").strip() if choice == "0": print("Edit cancelled.") return choice_index = int(choice) - 1 if 0 <= choice_index < len(sorted_editor_names): chosen_name = sorted_editor_names[choice_index] editor_to_use_path = found_editors[chosen_name] break else: print("Invalid choice.") except ValueError: print("Invalid input. Please enter a number.") except KeyboardInterrupt: print("\nEdit cancelled.") return if editor_to_use_path: self.logger.info(f"Launching editor '{editor_to_use_path}' for {self.hoolamike_config_path}") try: process = subprocess.Popen([editor_to_use_path, str(self.hoolamike_config_path)]) process.wait() self.logger.info(f"Editor '{editor_to_use_path}' closed. Reloading config...") if not self._load_hoolamike_config(): self.logger.error("Failed to load config after editing. It may still be corrupted.") print(f"{COLOR_ERROR}Warning: The configuration file could not be parsed after editing.{COLOR_RESET}") print("You may need to fix it manually or reset it to defaults.") return False else: self.logger.info("Successfully reloaded config after editing.") print(f"{COLOR_SUCCESS}Configuration file successfully updated.{COLOR_RESET}") return True except FileNotFoundError: self.logger.error(f"Editor '{editor_to_use_path}' not found unexpectedly.") print(f"{COLOR_ERROR}Error: Editor command '{editor_to_use_path}' not found.{COLOR_RESET}") except Exception as e: 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') print("Running HoolamikeHandler discovery...") handler = HoolamikeHandler(steamdeck=False, verbose=True) print("\n--- Discovery Results ---") print(f"Game Paths: {handler.game_install_paths}") print(f"Hoolamike App Install Path: {handler.hoolamike_app_install_path}") print(f"Hoolamike Executable: {handler.hoolamike_executable_path}") print(f"Hoolamike Installed: {handler.hoolamike_installed}") print(f"Hoolamike Config Path: {handler.hoolamike_config_path}") config_loaded = isinstance(handler.hoolamike_config, dict) print(f"Hoolamike Config Loaded: {config_loaded}") if config_loaded: print(f" Downloads Dir: {handler.hoolamike_config.get('downloaders', {}).get('downloads_directory')}") print(f" API Key Set: {'Yes' if handler.hoolamike_config.get('downloaders', {}).get('nexus', {}).get('api_key') != 'YOUR_API_KEY_HERE' else 'No'}") print("-------------------------") # Test edit config (example) # handler.edit_hoolamike_config()