#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Install Wabbajack Handler Module Handles the installation and updating of Wabbajack """ import os import logging from pathlib import Path from typing import Optional, Tuple import shutil import subprocess import pwd import requests from tqdm import tqdm import tempfile import time import re # Attempt to import readline for tab completion READLINE_AVAILABLE = False try: import readline READLINE_AVAILABLE = True # Check if running in a non-interactive environment (e.g., some CI) if 'libedit' in readline.__doc__: # libedit doesn't support set_completion_display_matches_hook pass # Add other potential checks if needed except ImportError: # readline not available on Windows or potentially minimal environments pass except Exception as e: # Catch other potential errors during readline import/setup logging.warning(f"Readline import failed: {e}") pass # Import UI Colors first - these should always be available from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR # Import necessary components from other modules try: from .path_handler import PathHandler from .protontricks_handler import ProtontricksHandler from .shortcut_handler import ShortcutHandler from .vdf_handler import VDFHandler from .modlist_handler import ModlistHandler from .filesystem_handler import FileSystemHandler from .menu_handler import MenuHandler, simple_path_completer # Standard logging (no file handler) - LoggingHandler import removed from .status_utils import show_status, clear_status from jackify.shared.ui_utils import print_section_header except ImportError as e: logging.error(f"Import error in InstallWabbajackHandler: {e}") logging.error("Could not import FileSystemHandler or simple_path_completer. Ensure structure is correct.") # Default locations WABBAJACK_DEFAULT_DIR = os.path.expanduser("~/.config/Jackify/Wabbajack") # Initialize logger for the module logger = logging.getLogger(__name__) DEFAULT_WABBAJACK_PATH = "~/Wabbajack" DEFAULT_WABBAJACK_NAME = "Wabbajack" class InstallWabbajackHandler: """Handles the workflow for installing Wabbajack via Jackify.""" def __init__(self, steamdeck: bool, protontricks_handler: ProtontricksHandler, shortcut_handler: ShortcutHandler, path_handler: PathHandler, vdf_handler: VDFHandler, modlist_handler: ModlistHandler, filesystem_handler: FileSystemHandler, menu_handler=None): """ Initializes the handler. Args: steamdeck (bool): True if running on a Steam Deck, False otherwise. protontricks_handler (ProtontricksHandler): An initialized instance. shortcut_handler (ShortcutHandler): An initialized instance. path_handler (PathHandler): An initialized instance. vdf_handler (VDFHandler): An initialized instance. modlist_handler (ModlistHandler): An initialized instance. filesystem_handler (FileSystemHandler): An initialized instance. menu_handler: An optional MenuHandler instance for improved UI interactions. """ # Use standard logging (no file handler) self.logger = logging.getLogger(__name__) self.logger.propagate = False self.steamdeck = steamdeck self.protontricks_handler = protontricks_handler # Store the handler self.shortcut_handler = shortcut_handler # Store the handler self.path_handler = path_handler # Store the handler self.vdf_handler = vdf_handler # Store the handler self.modlist_handler = modlist_handler # Store the handler self.filesystem_handler = filesystem_handler # Store the handler self.menu_handler = menu_handler # Store the menu handler self.logger.info(f"InstallWabbajackHandler initialized. Steam Deck status: {self.steamdeck}") self.install_path: Optional[Path] = None self.shortcut_name: Optional[str] = None self.initial_appid: Optional[str] = None # To store the AppID from shortcut creation self.final_appid: Optional[str] = None # To store the AppID after verification self.compatdata_path: Optional[Path] = None # To store the compatdata path # Add other state variables as needed def _print_default_status(self, message: str): """Prints overwriting status line, ONLY if not in verbose/debug mode.""" verbose_console = False for handler in logging.getLogger().handlers: if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler): if handler.level <= logging.INFO: verbose_console = True break if not verbose_console: # Use \r to return to start, \033[K to clear line, then print message # Prepend "Current Task: " to the message status_text = f"Current Task: {message}" # Use a fixed-width field for consistent display and proper line clearing status_width = 80 # Ensure sufficient width to cover previous text # Pad with spaces and use \r to stay on the same line print(f"\r\033[K{COLOR_INFO}{status_text:<{status_width}}{COLOR_RESET}", end="", flush=True) def _clear_default_status(self): """Clears the status line, ONLY if not in verbose/debug mode.""" verbose_console = False for handler in logging.getLogger().handlers: if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler): if handler.level <= logging.INFO: verbose_console = True break if not verbose_console: print("\r\033[K", end="", flush=True) def _download_file(self, url: str, destination_path: Path) -> bool: """Downloads a file from a URL to a destination path. Handles temporary file and overwrites destination if download succeeds. Args: url (str): The URL to download from. destination_path (Path): The path to save the downloaded file. Returns: bool: True if download succeeds, False otherwise. """ self.logger.info(f"Downloading {destination_path.name} from {url}") # Ensure parent directory exists destination_path.parent.mkdir(parents=True, exist_ok=True) # --- Download --- temp_path = destination_path.with_suffix(destination_path.suffix + ".part") self.logger.debug(f"Downloading to temporary path: {temp_path}") try: with requests.get(url, stream=True, timeout=30, verify=True) as r: r.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) # total_size_in_bytes = int(r.headers.get('content-length', 0)) block_size = 8192 # 8KB chunks with open(temp_path, 'wb') as f: for chunk in r.iter_content(chunk_size=block_size): if chunk: # filter out keep-alive new chunks f.write(chunk) # --- Post-Download Actions --- actual_downloaded_size = temp_path.stat().st_size self.logger.debug(f"Download finished. Actual size: {actual_downloaded_size} bytes.") # Overwrite final destination with temp file # Use shutil.move for better cross-filesystem compatibility if needed # temp_path.rename(destination_path) # Simple rename shutil.move(str(temp_path), str(destination_path)) self.logger.info(f"Successfully downloaded and moved to {destination_path}") return True except requests.exceptions.RequestException as e: self.logger.error(f"Download failed for {url}: {e}", exc_info=True) print(f"\n{COLOR_ERROR}Error downloading {destination_path.name}: {e}{COLOR_RESET}") # Clean up partial file if download fails if temp_path.exists(): try: temp_path.unlink() except OSError as unlink_err: self.logger.error(f"Failed to remove partial download {temp_path}: {unlink_err}") return False except Exception as e: self.logger.error(f"An unexpected error occurred during download: {e}", exc_info=True) print(f"\n{COLOR_ERROR}An unexpected error occurred during download: {e}{COLOR_RESET}") if temp_path.exists(): try: temp_path.unlink() except OSError as unlink_err: self.logger.error(f"Failed to remove partial download {temp_path}: {unlink_err}") return False def _prepare_install_directory(self) -> bool: """ Ensures the target installation directory exists and is accessible. Handles directory creation, prompting the user if outside $HOME. Returns: bool: True if the directory exists and is ready, False otherwise. """ if not self.install_path: self.logger.error("Cannot prepare directory: install_path is not set.") return False self.logger.info(f"Preparing installation directory: {self.install_path}") if self.install_path.exists(): if self.install_path.is_dir(): self.logger.info(f"Directory already exists: {self.install_path}") # Check write permissions if not os.access(self.install_path, os.W_OK | os.X_OK): self.logger.error(f"Directory exists but lacks write/execute permissions: {self.install_path}") print(f"\n{COLOR_ERROR}Error: Directory exists but lacks necessary write/execute permissions.{COLOR_RESET}") return False return True else: self.logger.error(f"Path exists but is not a directory: {self.install_path}") print(f"\n{COLOR_ERROR}Error: The specified path exists but is a file, not a directory.{COLOR_RESET}") return False else: # Directory does not exist, attempt creation self.logger.info("Directory does not exist. Attempting creation...") try: home_dir = Path.home() is_outside_home = not str(self.install_path.resolve()).startswith(str(home_dir.resolve())) if is_outside_home: self.logger.warning(f"Install path {self.install_path} is outside home directory {home_dir}.") print(f"\n{COLOR_PROMPT}The chosen path is outside your home directory and may require manual creation.{COLOR_RESET}") while True: response = input(f"{COLOR_PROMPT}Please create the directory \"{self.install_path}\" manually,\nensure you have write permissions, and then press Enter to continue (or 'q' to quit): {COLOR_RESET}").lower() if response == 'q': self.logger.warning("User aborted manual directory creation.") return False # Re-check after user presses Enter if self.install_path.exists(): if self.install_path.is_dir(): self.logger.info("Directory created manually by user.") if not os.access(self.install_path, os.W_OK | os.X_OK): self.logger.warning(f"Directory created but may lack write/execute permissions: {self.install_path}") print(f"\n{COLOR_ERROR}Warning: Directory created, but write/execute permissions might be missing.{COLOR_RESET}") # Decide whether to proceed or fail here - let's proceed but warn return True else: self.logger.error("User indicated directory created, but path is not a directory.") print(f"\n{COLOR_ERROR}Error: Path exists now, but it is not a directory. Please fix and try again.{COLOR_RESET}") else: print(f"\n{COLOR_ERROR}Directory still not found. Please create it or enter 'q' to quit.{COLOR_RESET}") else: # Inside home directory, attempt direct creation self.logger.info("Path is inside home directory. Creating...") os.makedirs(self.install_path) self.logger.info(f"Successfully created directory: {self.install_path}") # Verify permissions after creation if not os.access(self.install_path, os.W_OK | os.X_OK): self.logger.warning(f"Directory created but lacks write/execute permissions: {self.install_path}") print(f"\n{COLOR_ERROR}Warning: Directory created, but lacks write/execute permissions. Subsequent steps might fail.{COLOR_RESET}") # Proceed anyway? return True except PermissionError: self.logger.error(f"Permission denied when trying to create directory: {self.install_path}", exc_info=True) print(f"\n{COLOR_ERROR}Error: Permission denied creating directory.{COLOR_RESET}") print(f"{COLOR_INFO}Please check permissions for the parent directory or choose a different location.{COLOR_RESET}") return False except OSError as e: self.logger.error(f"Failed to create directory {self.install_path}: {e}", exc_info=True) print(f"\n{COLOR_ERROR}Error creating directory: {e}{COLOR_RESET}") return False except Exception as e: self.logger.error(f"An unexpected error occurred during directory preparation: {e}", exc_info=True) print(f"\n{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") return False def _get_wabbajack_install_path(self) -> Optional[Path]: """ Prompts the user for the Wabbajack installation path with tab completion. Uses the FileSystemHandler for path validation and completion. Returns: Optional[Path]: The chosen installation path as a Path object, or None if cancelled. """ self.logger.info("Prompting for Wabbajack installation path.") # Use default path if set, otherwise prompt with suggestion current_path = self.install_path if self.install_path else Path(DEFAULT_WABBAJACK_PATH).expanduser() # Enable tab completion if readline is available if READLINE_AVAILABLE: readline.set_completer_delims(' \t\n;') readline.parse_and_bind("tab: complete") # Use the simple_path_completer from FileSystemHandler for directory completion readline.set_completer(simple_path_completer) while True: try: prompt_text = f"{COLOR_PROMPT}Enter Wabbajack installation path (default: {current_path}): {COLOR_RESET}" user_input = input(prompt_text).strip() if not user_input: # User pressed Enter for default chosen_path_str = str(current_path) else: chosen_path_str = user_input # Expand ~ and make absolute chosen_path = Path(chosen_path_str).expanduser().resolve() # Basic validation (is it a plausible path format?) if not chosen_path.name: # e.g. if user entered just "/" print(f"{COLOR_ERROR}Invalid path. Please enter a valid directory path.{COLOR_RESET}") continue # Check if path exists and is a directory, or can be created if chosen_path.exists() and not chosen_path.is_dir(): print(f"{COLOR_ERROR}Path exists but is not a directory: {chosen_path}{COLOR_RESET}") continue # Confirm with user confirm_prompt = f"{COLOR_PROMPT}Install Wabbajack to {chosen_path}? (Y/n/c to cancel): {COLOR_RESET}" confirmation = input(confirm_prompt).lower() if confirmation == 'c': self.logger.info("Wabbajack installation path selection cancelled by user.") return None # User cancelled elif confirmation != 'n': self.install_path = chosen_path # Store the confirmed path self.logger.info(f"Wabbajack installation path set to: {self.install_path}") return self.install_path # If 'n', loop again to ask for path except KeyboardInterrupt: self.logger.info("Wabbajack installation path selection cancelled by user (Ctrl+C).") print("\nPath selection cancelled.") return None except Exception as e: self.logger.error(f"Error during path input: {e}", exc_info=True) print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") # Decide if we should return None or retry on general exception return None finally: # Restore default completer if it was changed if READLINE_AVAILABLE: readline.set_completer(None) def _get_wabbajack_shortcut_name(self) -> Optional[str]: """ Prompts the user for the Wabbajack shortcut name. Returns: Optional[str]: The name chosen by the user, or None if cancelled. """ self.logger.debug("Getting Wabbajack shortcut name.") # Return pre-configured shortcut name if already set if self.shortcut_name: self.logger.info(f"Using pre-configured shortcut name: {self.shortcut_name}") return self.shortcut_name chosen_name = DEFAULT_WABBAJACK_NAME # Use menu_handler if available for consistent UI if self.menu_handler: self.logger.debug("Using menu_handler for shortcut name input") print(f"\nWabbajack Shortcut Name:") name_input = self.menu_handler.get_input_with_default( prompt=f"Enter the desired name for the Wabbajack Steam shortcut (default: {chosen_name})", default=chosen_name ) if name_input is not None: self.logger.info(f"User provided shortcut name: {name_input}") return name_input else: self.logger.info("User cancelled shortcut name input") return None # Fallback to direct input if no menu_handler try: print(f"\n{COLOR_PROMPT}Enter the desired name for the Wabbajack Steam shortcut.{COLOR_RESET}") name_input = input(f"{COLOR_PROMPT}Name [{chosen_name}]: {COLOR_RESET}").strip() if not name_input: self.logger.info(f"User did not provide input, using default name: {chosen_name}") else: chosen_name = name_input self.logger.info(f"User provided name: {chosen_name}") return chosen_name except KeyboardInterrupt: print(f"\n{COLOR_ERROR}Input cancelled by user.{COLOR_RESET}") self.logger.warning("User cancelled name input.") return None except Exception as e: self.logger.error(f"An unexpected error occurred while getting name input: {e}", exc_info=True) return None def run_install_workflow(self, context: dict = None) -> bool: """ Main entry point for the Wabbajack installation workflow. """ os.system('cls' if os.name == 'nt' else 'clear') # Banner display handled by frontend print_section_header('Wabbajack Installation') # Standard logging (no file handler) - LoggingHandler calls removed self.logger.info("Starting Wabbajack installation workflow...") # Remove legacy divider # print(f"\n{COLOR_INFO}--- Wabbajack Installation ---{COLOR_RESET}") # 1. Get Installation Path if self.menu_handler: print("\nWabbajack Installation Location:") default_path = Path.home() / 'Wabbajackify' install_path_result = self.menu_handler.get_directory_path( prompt_message=f"Enter path (Default: {default_path}):", default_path=default_path, create_if_missing=True, no_header=True ) if not install_path_result: self.logger.info("User cancelled path input via menu_handler") return True # Return to menu to allow user to retry or exit gracefully # Handle the result from get_directory_path (could be Path or tuple) if isinstance(install_path_result, tuple): self.install_path = install_path_result[0] # Path object self.logger.info(f"Install path set to {self.install_path}, user confirmed creation if new.") else: self.install_path = install_path_result # Already a Path object self.logger.info(f"Install path set to {self.install_path}.") else: # Fallback if no menu_handler (should ideally not happen in normal flow) default_path = Path.home() / 'Wabbajackify' print(f"\n{COLOR_PROMPT}Enter the full path where Wabbajack should be installed.{COLOR_RESET}") print(f"Default: {default_path}") try: user_input = input(f"{COLOR_PROMPT}Enter path (or press Enter for default: {default_path}): {COLOR_RESET}").strip() if not user_input: install_path = default_path else: install_path = Path(user_input).expanduser().resolve() self.install_path = install_path except KeyboardInterrupt: print("\nOperation cancelled by user.") self.logger.info("User cancelled path input.") return True # 2. Get Shortcut Name self.shortcut_name = self._get_wabbajack_shortcut_name() if not self.shortcut_name: self.logger.warning("Workflow aborted: Failed to get shortcut name.") return True # Return to menu # 3. Steam Deck status is already known (self.steamdeck) self.logger.info(f"Proceeding with Steam Deck status: {self.steamdeck}") # 4. Check Prerequisite: Protontricks self.logger.info("Checking Protontricks prerequisite...") protontricks_ok = self.protontricks_handler.check_and_setup_protontricks() if not protontricks_ok: self.logger.error("Workflow aborted: Protontricks requirement not met or setup failed.") input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") return True # Return to menu self.logger.info("Protontricks check successful.") # --- Show summary (no input required) --- self._display_summary() # Show the summary only, no input here # --- Single confirmation prompt before making changes/restarting Steam --- print("\n───────────────────────────────────────────────────────────────────") print(f"{COLOR_PROMPT}Important:{COLOR_RESET} Steam will now restart so Jackify can create the Wabbajack shortcut.\n\nPlease do not manually start or close Steam until Jackify is finished.") print("───────────────────────────────────────────────────────────────────") confirm = input(f"{COLOR_PROMPT}Do you wish to continue? (y/N): {COLOR_RESET}").strip().lower() if confirm not in ('y', ''): print("Installation cancelled by user.") return True # --- Phase 2: All changes happen after confirmation --- # 5. Prepare Install Directory show_status("Preparing install directory") if not self._prepare_install_directory(): self.logger.error("Workflow aborted: Failed to prepare installation directory.") clear_status() input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") return True # Return to menu self.logger.info("Installation directory prepared successfully.") # 6. Download Wabbajack.exe show_status("Downloading Wabbajack.exe") if not self._download_wabbajack_executable(): self.logger.error("Workflow aborted: Failed to download Wabbajack.exe.") clear_status() input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") return True # Return to menu clear_status() # 7. Create Steam Shortcut show_status("Creating Steam shortcut") shortcut_created = self._create_steam_shortcut() clear_status() if not shortcut_created: self.logger.error("Workflow aborted: Failed to create Steam shortcut.") input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") return True # Return to menu # Print the AppID immediately after shortcut creation, before any other output print("\n==================== Steam Shortcut Created ====================") if self.initial_appid: print(f"{COLOR_INFO}Initial Steam AppID (before Steam restart): {self.initial_appid}{COLOR_RESET}") else: print(f"{COLOR_ERROR}Warning: Could not determine initial AppID after shortcut creation.{COLOR_RESET}") print("==============================================================\n") # 8. Handle Steam Restart & Manual Steps (Calls _print_default_status internally) if not self._handle_steam_restart_and_manual_steps(): # Status already cleared by the function if needed self.logger.info("Workflow aborted: Steam restart/manual steps issue or user needs to re-run.") return True # Return to menu, user needs to act # 9. Verify Manual Steps # Move cursor up, return to start, clear line - attempt to overwrite input prompt line print("\033[A\r\033[K", end="", flush=True) show_status("Verifying Proton Setup") while True: if self._verify_manual_steps(): show_status("Manual Steps Successful") # Print the AppID after Steam restart and re-detection if self.final_appid: print(f"\n{COLOR_INFO}Final Steam AppID (after Steam restart): {self.final_appid}{COLOR_RESET}") else: print(f"\n{COLOR_ERROR}Warning: Could not determine AppID after Steam restart.{COLOR_RESET}") break # Verification successful else: self.logger.warning("Manual steps verification failed.") clear_status() # Clear status before printing error/prompt print(f"\n{COLOR_ERROR}Verification failed. Please ensure you have completed all manual steps correctly.{COLOR_RESET}") self._display_manual_proton_steps() # Re-display steps try: # Add a newline before the input prompt for clarity response = input(f"\n{COLOR_PROMPT}Press Enter to retry verification, or 'q' to quit: {COLOR_RESET}").lower() if response == 'q': self.logger.warning("User quit during verification loop.") return True # Return to menu, aborting config show_status("Retrying Verification") except KeyboardInterrupt: clear_status() print("\nOperation cancelled by user.") self.logger.warning("User cancelled during verification loop.") return True # Return to menu # --- Start Actual Configuration --- self.logger.info(f"Starting final configuration for AppID {self.final_appid}...") # logger.info("--- Configuration --- Applying final configurations...") # Keep this log for file # Check console level for verbose output verbose_console = False for handler in logging.getLogger().handlers: if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler): if handler.level <= logging.INFO: # Check if INFO or DEBUG verbose_console = True break if verbose_console: print(f"{COLOR_INFO}Applying final configurations...{COLOR_RESET}") # 10. Set Protontricks Permissions (Flatpak) show_status("Setting Protontricks permissions") if not self.protontricks_handler.set_protontricks_permissions(str(self.install_path), self.steamdeck): self.logger.warning("Failed to set Flatpak Protontricks permissions. Continuing, but subsequent steps might fail if Flatpak Protontricks is used.") clear_status() # Clear status before printing warning print(f"\n{COLOR_ERROR}Warning: Could not set Flatpak permissions automatically.{COLOR_RESET}") # 12. Download WebView Installer (Check happens BEFORE setting prefix) show_status("Checking WebView Installer") if not self._download_webview_installer(): self.logger.error("Workflow aborted: Failed to download WebView installer.") # Error message printed by the download function clear_status() input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") return True # Return to menu # 13. Configure Prefix (Set to Win7 for WebView install) show_status("Applying Initial Win7 Registry Settings (for WebView install)") try: import requests # Download minimal Win7 system.reg (corrected URL) system_reg_win7_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/system.reg.wj.win7" system_reg_dest = self.compatdata_path / 'pfx' / 'system.reg' system_reg_dest.parent.mkdir(parents=True, exist_ok=True) self.logger.info(f"Downloading system.reg.wj.win7 from {system_reg_win7_url} to {system_reg_dest}") response = requests.get(system_reg_win7_url, verify=True) response.raise_for_status() with open(system_reg_dest, "wb") as f: f.write(response.content) self.logger.info(f"system.reg.wj.win7 downloaded and applied to {system_reg_dest}") except Exception as e: self.logger.error(f"Failed to download or apply initial Win7 system.reg: {e}") print(f"{COLOR_ERROR}Error: Failed to download or apply initial Win7 system.reg. {e}{COLOR_RESET}") clear_status() input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") return True # 14. Install WebView (using protontricks-launch) show_status("Installing WebView (Edge)") webview_installer_path = self.install_path / "MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" webview_result = self.protontricks_handler.run_protontricks_launch( self.final_appid, webview_installer_path, "/silent", "/install" ) self.logger.debug(f"WebView install result: {webview_result}") if not webview_result or webview_result.returncode != 0: self.logger.error("WebView installation failed via protontricks-launch.") print(f"{COLOR_ERROR}Error: WebView installation failed via protontricks-launch.{COLOR_RESET}") clear_status() input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") return True show_status("WebView installation Complete") # 15. Configure Prefix (Part 2 - Final Settings) show_status("Applying Final Registry Settings") try: # Download final system.reg system_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/system.reg.wj" system_reg_dest = self.compatdata_path / 'pfx' / 'system.reg' self.logger.info(f"Downloading final system.reg from {system_reg_url} to {system_reg_dest}") response = requests.get(system_reg_url, verify=True) response.raise_for_status() with open(system_reg_dest, "wb") as f: f.write(response.content) self.logger.info(f"Final system.reg downloaded and applied to {system_reg_dest}") # Download final user.reg user_reg_url = "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/refs/heads/main/files/user.reg.wj" user_reg_dest = self.compatdata_path / 'pfx' / 'user.reg' self.logger.info(f"Downloading final user.reg from {user_reg_url} to {user_reg_dest}") response = requests.get(user_reg_url, verify=True) response.raise_for_status() with open(user_reg_dest, "wb") as f: f.write(response.content) self.logger.info(f"Final user.reg downloaded and applied to {user_reg_dest}") except Exception as e: self.logger.error(f"Failed to download or apply final user.reg/system.reg: {e}") print(f"{COLOR_ERROR}Error: Failed to download or apply final user.reg/system.reg. {e}{COLOR_RESET}") clear_status() input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") return True # 16. Configure Prefix Steam Library VDF show_status("Configuring Steam Library in Prefix") if not self._create_prefix_library_vdf(): return False # 17. Create Dotnet Bundle Cache Directory show_status("Creating .NET Cache Directory") if not self._create_dotnet_cache_dir(): self.logger.error("Workflow aborted: Failed to create dotnet cache directory.") clear_status() input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") return True # Return to menu # --- Final Steps --- # Check for and optionally apply Flatpak overrides *before* final cleanup/completion self._check_and_prompt_flatpak_overrides() # Attempt to clean up any stray Wine/Protontricks processes as a final measure self.logger.info("Performing final Wine process cleanup...") try: # Ensure the ProtontricksHandler instance exists and has the method if hasattr(self, 'protontricks_handler') and hasattr(self.protontricks_handler, '_cleanup_wine_processes'): self.protontricks_handler._cleanup_wine_processes() self.logger.info("Wine process cleanup command executed.") else: self.logger.warning("Protontricks handler or cleanup method not available, skipping cleanup.") except Exception as cleanup_e: self.logger.error(f"Error during final Wine process cleanup: {cleanup_e}", exc_info=True) # Don't abort the whole workflow for a cleanup failure, just log it. # 18b. Display Completion Message clear_status() self._display_completion_message() # End of successful workflow self.logger.info("Wabbajack installation workflow completed successfully.") clear_status() # Clear status before final prompt input(f"\n{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}") return True # Return to menu def _display_summary(self): """Displays a summary of settings (no confirmation prompt).""" if not self.install_path or not self.shortcut_name: self.logger.error("Cannot display summary: Install path or shortcut name missing.") return False # Should not happen if called at the right time print("\n───────────────────────────────────────────────────────────────────") print(f"{COLOR_PROMPT}--- Installation Summary ---{COLOR_RESET}") print(f" Install Path: {self.install_path}") print(f" Shortcut Name: {self.shortcut_name}") print(f" Environment: {'Steam Deck' if self.steamdeck else 'Desktop Linux'}") print(f" Protontricks: {self.protontricks_handler.which_protontricks or 'Unknown'}") print("───────────────────────────────────────────────────────────────────") return True def _backup_and_replace_final_reg_files(self) -> bool: """Backs up current reg files and replaces them with the final downloaded versions.""" if not self.compatdata_path: self.logger.error("Cannot backup/replace reg files: compatdata_path not set.") return False pfx_path = self.compatdata_path / 'pfx' system_reg = pfx_path / 'system.reg' user_reg = pfx_path / 'user.reg' system_reg_bak = pfx_path / 'system.reg.orig' user_reg_bak = pfx_path / 'user.reg.orig' # Backup existing files self.logger.info("Backing up existing registry files...") logger.info("Backing up current registry files...") try: if system_reg.exists(): shutil.copy2(system_reg, system_reg_bak) self.logger.debug(f"Backed up {system_reg} to {system_reg_bak}") else: self.logger.warning(f"Original {system_reg} not found for backup.") if user_reg.exists(): shutil.copy2(user_reg, user_reg_bak) self.logger.debug(f"Backed up {user_reg} to {user_reg_bak}") else: self.logger.warning(f"Original {user_reg} not found for backup.") except Exception as e: self.logger.error(f"Error backing up registry files: {e}", exc_info=True) print(f"{COLOR_ERROR}Error backing up registry files: {e}{COLOR_RESET}") return False # Treat backup failure as critical? # Define final registry file URLs final_system_reg_url = "https://github.com/Omni-guides/Wabbajack-Modlist-Linux/raw/refs/heads/main/files/system.reg.github" final_user_reg_url = "https://github.com/Omni-guides/Wabbajack-Modlist-Linux/raw/refs/heads/main/files/user.reg.github" # Download and replace logger.info("Downloading and applying final registry settings...") system_ok = self._download_and_replace_reg_file(final_system_reg_url, system_reg) user_ok = self._download_and_replace_reg_file(final_user_reg_url, user_reg) if system_ok and user_ok: self.logger.info("Successfully applied final registry files.") return True else: self.logger.error("Failed to download or replace one or both final registry files.") print(f"{COLOR_ERROR}Error: Failed to apply final registry settings.{COLOR_RESET}") # Should we attempt to restore backups here? return False def _install_webview(self) -> bool: """Installs the WebView2 runtime using protontricks-launch.""" if not self.final_appid or not self.install_path: self.logger.error("Cannot install WebView: final_appid or install_path not set.") return False installer_name = "MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" installer_path = self.install_path / installer_name if not installer_path.is_file(): self.logger.error(f"WebView installer not found at {installer_path}. Cannot install.") print(f"{COLOR_ERROR}Error: WebView installer file missing. Please ensure step 12 completed.{COLOR_RESET}") return False self.logger.info(f"Starting WebView installation for AppID {self.final_appid}...") # Remove print, handled by caller # print("\nInstalling WebView (this can take a while, please be patient)...") cmd_prefix = [] if self.protontricks_handler.which_protontricks == 'flatpak': # Using full command path is safer than relying on alias being sourced cmd_prefix = ["flatpak", "run", "--command=protontricks-launch", "com.github.Matoking.protontricks"] else: launch_path = shutil.which("protontricks-launch") if not launch_path: self.logger.error("protontricks-launch command not found in PATH.") print(f"{COLOR_ERROR}Error: protontricks-launch command not found.{COLOR_RESET}") return False cmd_prefix = [launch_path] # Arguments for protontricks-launch args = ["--appid", self.final_appid, str(installer_path), "/silent", "/install"] full_cmd = cmd_prefix + args self.logger.debug(f"Executing WebView install command: {' '.join(full_cmd)}") try: # Use check=True to raise CalledProcessError on non-zero exit # Set a longer timeout as this can take time. result = subprocess.run(full_cmd, check=True, capture_output=True, text=True, timeout=600) # 10 minute timeout self.logger.info("WebView installation command completed successfully.") # Do NOT log result.stdout or result.stderr here return True except FileNotFoundError: self.logger.error(f"Command not found: {cmd_prefix[0]}") print(f"{COLOR_ERROR}Error: Could not execute {cmd_prefix[0]}. Is it installed correctly?{COLOR_RESET}") return False except subprocess.TimeoutExpired: self.logger.error("WebView installation timed out after 10 minutes.") print(f"{COLOR_ERROR}Error: WebView installation took too long and timed out.{COLOR_RESET}") return False except subprocess.CalledProcessError as e: self.logger.error(f"WebView installation failed with return code {e.returncode}") # Only log a short snippet of output for debugging self.logger.error(f"STDERR (truncated):\n{e.stderr[:500] if e.stderr else ''}") print(f"{COLOR_ERROR}Error: WebView installation failed (Return Code: {e.returncode}). Check logs for details.{COLOR_RESET}") return False except Exception as e: self.logger.error(f"Unexpected error during WebView installation: {e}", exc_info=True) print(f"{COLOR_ERROR}An unexpected error occurred during WebView installation: {e}{COLOR_RESET}") return False def _find_steam_library_and_vdf_path(self) -> Tuple[Optional[Path], Optional[Path]]: """Finds the Steam library root and the path to the real libraryfolders.vdf.""" self.logger.info("Attempting to find Steam library and libraryfolders.vdf...") try: # Check if PathHandler uses static methods or needs instantiation if isinstance(self.path_handler, type): common_path = self.path_handler.find_steam_library() else: common_path = self.path_handler.find_steam_library() if not common_path or not common_path.is_dir(): self.logger.error("Could not find Steam library common path.") return None, None # Navigate up to find the library root library_root = common_path.parent.parent # steamapps/common -> steamapps -> library_root self.logger.debug(f"Deduced library root: {library_root}") # Construct path to the real libraryfolders.vdf # Common locations relative to library root vdf_path_candidates = [ library_root / 'config/libraryfolders.vdf', # For non-Flatpak? ~/.steam/steam/config library_root / '../config/libraryfolders.vdf' # Flatpak? ~/.var/app/../Steam/config ] real_vdf_path = None for candidate in vdf_path_candidates: resolved_candidate = candidate.resolve() # Resolve symlinks/.. parts if resolved_candidate.is_file(): real_vdf_path = resolved_candidate self.logger.info(f"Found real libraryfolders.vdf at: {real_vdf_path}") break if not real_vdf_path: self.logger.error(f"Could not find libraryfolders.vdf within library root: {library_root}") return None, None return library_root, real_vdf_path except Exception as e: self.logger.error(f"Error finding Steam library/VDF: {e}", exc_info=True) return None, None def _link_steam_library_config(self) -> bool: """Creates the necessary directory structure and symlinks libraryfolders.vdf.""" if not self.compatdata_path: self.logger.error("Cannot link Steam library: compatdata_path not set.") return False self.logger.info("Linking Steam library configuration (libraryfolders.vdf)...") library_root, real_vdf_path = self._find_steam_library_and_vdf_path() if not library_root or not real_vdf_path: print(f"{COLOR_ERROR}Error: Could not locate Steam library or libraryfolders.vdf.{COLOR_RESET}") return False target_dir = self.compatdata_path / 'pfx/drive_c/Program Files (x86)/Steam/config' link_path = target_dir / 'libraryfolders.vdf' try: # Backup the original libraryfolders.vdf before doing anything else # Use FileSystemHandler for consistency - NOW USE INSTANCE self.logger.debug(f"Backing up original libraryfolders.vdf: {real_vdf_path}") if not self.filesystem_handler.backup_file(real_vdf_path): self.logger.warning(f"Failed to backup {real_vdf_path}. Proceeding with caution.") # Optionally, prompt user or fail here? For now, just warn. print(f"{COLOR_ERROR}Warning: Failed to create backup of libraryfolders.vdf.{COLOR_RESET}") # Create the target directory self.logger.debug(f"Creating directory: {target_dir}") os.makedirs(target_dir, exist_ok=True) # Remove existing symlink if it exists if link_path.is_symlink(): self.logger.debug(f"Removing existing symlink at {link_path}") link_path.unlink() elif link_path.exists(): # It exists but isn't a symlink - this is unexpected self.logger.warning(f"Path {link_path} exists but is not a symlink. Removing it.") if link_path.is_dir(): shutil.rmtree(link_path) else: link_path.unlink() # Create the symlink self.logger.info(f"Creating symlink from {real_vdf_path} to {link_path}") os.symlink(real_vdf_path, link_path) # Verification (optional but good) if link_path.is_symlink() and link_path.resolve() == real_vdf_path.resolve(): self.logger.info("Symlink created and verified successfully.") return True else: self.logger.error("Symlink creation failed or verification failed.") return False except OSError as e: self.logger.error(f"OSError during symlink creation: {e}", exc_info=True) print(f"{COLOR_ERROR}Error creating Steam library link: {e}{COLOR_RESET}") return False except Exception as e: self.logger.error(f"Unexpected error during symlink creation: {e}", exc_info=True) print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") return False def _create_prefix_library_vdf(self) -> bool: """Creates the necessary directory structure and copies a modified libraryfolders.vdf.""" if not self.compatdata_path: self.logger.error("Cannot create prefix VDF: compatdata_path not set.") return False self.logger.info("Creating modified libraryfolders.vdf in prefix...") # 1. Find the real host VDF file library_root, real_vdf_path = self._find_steam_library_and_vdf_path() if not real_vdf_path: # Error logged by _find_steam_library_and_vdf_path print(f"{COLOR_ERROR}Error: Could not locate real libraryfolders.vdf.{COLOR_RESET}") return False # 2. Backup the real VDF file self.logger.debug(f"Backing up original libraryfolders.vdf: {real_vdf_path}") if not self.filesystem_handler.backup_file(real_vdf_path): self.logger.warning(f"Failed to backup {real_vdf_path}. Proceeding with caution.") print(f"{COLOR_ERROR}Warning: Failed to create backup of libraryfolders.vdf.{COLOR_RESET}") # 3. Define target location in prefix target_dir = self.compatdata_path / 'pfx/drive_c/Program Files (x86)/Steam/config' target_vdf_path = target_dir / 'libraryfolders.vdf' try: # 4. Read the content of the real VDF self.logger.debug(f"Reading content from {real_vdf_path}") vdf_content = real_vdf_path.read_text(encoding='utf-8') # 5. Convert Linux paths to Wine paths within the content string modified_content = vdf_content # Regex to find "path" "/linux/path" entries reliably path_pattern = re.compile(r'("path"\s*")([^"]+)(")') # Use a function for replacement logic to handle potential errors def replace_path(match): prefix, linux_path_str, suffix = match.groups() self.logger.debug(f"Found path entry to convert: {linux_path_str}") try: linux_path = Path(linux_path_str) # Check if it's an SD card path if self.filesystem_handler.is_sd_card(linux_path): # Assuming SD card maps to D: # Remove prefix like /run/media/mmcblk0p1/ relative_sd_path_str = self.filesystem_handler._strip_sdcard_path_prefix(linux_path) wine_path = "D:\\" + relative_sd_path_str.replace('/', '\\') self.logger.debug(f" Converted SD card path: {linux_path_str} -> {wine_path}") else: # Assume non-SD maps relative to Z: # Need the full path prefixed with Z: wine_path = "Z:\\" + linux_path_str.strip('/').replace('/', '\\') self.logger.debug(f" Converted standard path: {linux_path_str} -> {wine_path}") # Ensure backslashes are doubled for VDF format wine_path_vdf_escaped = wine_path.replace('\\', '\\\\') return f'{prefix}{wine_path_vdf_escaped}{suffix}' except Exception as e: self.logger.error(f"Error converting path '{linux_path_str}': {e}. Keeping original.") return match.group(0) # Return original match on error # Perform the replacement using re.sub with the function modified_content = path_pattern.sub(replace_path, vdf_content) # Log comparison if content changed (optional) if modified_content != vdf_content: self.logger.info("Successfully converted Linux paths to Wine paths in VDF content.") else: self.logger.warning("VDF content unchanged after conversion attempt. Did it contain Linux paths?") # 6. Ensure target directory exists self.logger.debug(f"Ensuring target directory exists: {target_dir}") os.makedirs(target_dir, exist_ok=True) # 7. Write the modified content to the target file in the prefix self.logger.info(f"Writing modified VDF content to {target_vdf_path}") target_vdf_path.write_text(modified_content, encoding='utf-8') # 8. Verification (optional: check file exists and content) if target_vdf_path.is_file(): self.logger.info("Prefix libraryfolders.vdf created successfully.") return True else: self.logger.error("Failed to create prefix libraryfolders.vdf.") return False except Exception as e: self.logger.error(f"Error processing or writing prefix libraryfolders.vdf: {e}", exc_info=True) print(f"{COLOR_ERROR}An error occurred configuring the Steam library in the prefix: {e}{COLOR_RESET}") return False def _create_dotnet_cache_dir(self) -> bool: """Creates the dotnet_bundle_extract cache directory.""" if not self.install_path: self.logger.error("Cannot create dotnet cache dir: install_path not set.") return False try: # Get username reliably username = pwd.getpwuid(os.getuid()).pw_name # Fallback if pwd fails for some reason? # username = os.getlogin() # Can fail in some environments except Exception as e: self.logger.error(f"Could not determine username: {e}") print(f"{COLOR_ERROR}Error: Could not determine username to create cache directory.{COLOR_RESET}") return False cache_dir = self.install_path / 'home' / username / '.cache' / 'dotnet_bundle_extract' self.logger.info(f"Creating dotnet bundle cache directory: {cache_dir}") try: os.makedirs(cache_dir, exist_ok=True) # Optionally set permissions? The bash script didn't explicitly. self.logger.info("dotnet cache directory created successfully.") return True except OSError as e: self.logger.error(f"Failed to create dotnet cache directory {cache_dir}: {e}", exc_info=True) print(f"{COLOR_ERROR}Error creating dotnet cache directory: {e}{COLOR_RESET}") return False except Exception as e: self.logger.error(f"Unexpected error creating dotnet cache directory: {e}", exc_info=True) print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") return False def _check_and_prompt_flatpak_overrides(self): """Checks if Flatpak Steam needs filesystem overrides and prompts the user to apply them.""" self.logger.info("Checking for necessary Flatpak Steam filesystem overrides...") is_flatpak_steam = False # Use compatdata_path as indicator if self.compatdata_path and ".var/app/com.valvesoftware.Steam" in str(self.compatdata_path): is_flatpak_steam = True self.logger.debug("Flatpak Steam detected based on compatdata path.") # Add other checks if needed (e.g., check if `flatpak info com.valvesoftware.Steam` runs) if not is_flatpak_steam: self.logger.info("Flatpak Steam not detected, skipping override check.") return paths_to_check = [] if self.install_path: paths_to_check.append(self.install_path) # Get all library paths from libraryfolders.vdf try: all_libs = self.path_handler.get_all_steam_libraries() paths_to_check.extend(all_libs) except Exception as e: self.logger.warning(f"Could not get all Steam libraries to check for overrides: {e}") needed_overrides = set() # Use a set to store unique parent paths needing override home_dir = Path.home() flatpak_steam_data_dir = home_dir / ".var/app/com.valvesoftware.Steam" for path in paths_to_check: if not path: continue resolved_path = path.resolve() # Check if path is outside $HOME AND outside the Flatpak data dir is_outside_home = not str(resolved_path).startswith(str(home_dir)) is_outside_flatpak_data = not str(resolved_path).startswith(str(flatpak_steam_data_dir)) if is_outside_home and is_outside_flatpak_data: # Need override for the parent directory containing this path # Go up levels until we find a reasonable base (e.g., /mnt/Games, /data/Steam) # Avoid adding /, /home, etc. parent_to_add = resolved_path.parent while parent_to_add != parent_to_add.parent and len(str(parent_to_add)) > 1 and parent_to_add.name != 'home': # Check if adding this parent makes sense (e.g., it exists, not too high up) if parent_to_add.is_dir(): # Simple check for existence # Further heuristics could be added here needed_overrides.add(str(parent_to_add)) self.logger.debug(f"Path {resolved_path} is outside sandbox. Adding parent {parent_to_add} to needed overrides.") break # Add the first reasonable parent found parent_to_add = parent_to_add.parent if not needed_overrides: self.logger.info("No external paths requiring Flatpak overrides detected.") return # Construct the command string(s) override_commands = [] for path_str in sorted(list(needed_overrides)): # Add specific path override override_commands.append(f"flatpak override --user --filesystem=\"{path_str}\" com.valvesoftware.Steam") # Combine into a single string for display, but keep list for execution command_display = "\n".join([f" {cmd}" for cmd in override_commands]) print(f"\n{COLOR_PROMPT}--- Flatpak Steam Permissions ---{COLOR_RESET}") print("Jackify has detected that you are using Flatpak Steam and have paths") print("(e.g., Wabbajack install location or other Steam libraries) outside") print("the standard Flatpak sandbox. For Wabbajack to access these locations,") print("Steam needs the following filesystem permissions:") print(f"{COLOR_INFO}{command_display}{COLOR_RESET}") print("───────────────────────────────────────────────────────────────────") try: confirm = input(f"{COLOR_PROMPT}Do you want Jackify to apply these permissions now? (y/N): {COLOR_RESET}").lower().strip() if confirm == 'y': self.logger.info("User confirmed applying Flatpak overrides.") success_count = 0 for cmd_str in override_commands: self.logger.info(f"Executing: {cmd_str}") try: # Split command string for subprocess cmd_list = cmd_str.split() result = subprocess.run(cmd_list, check=True, capture_output=True, text=True, timeout=30) self.logger.debug(f"Override command successful: {result.stdout}") success_count += 1 except FileNotFoundError: self.logger.error(f"'flatpak' command not found. Cannot apply override: {cmd_str}") print(f"{COLOR_ERROR}Error: 'flatpak' command not found.{COLOR_RESET}") break # Stop trying if flatpak isn't found except subprocess.TimeoutExpired: self.logger.error(f"Flatpak override command timed out: {cmd_str}") print(f"{COLOR_ERROR}Error: Command timed out: {cmd_str}{COLOR_RESET}") except subprocess.CalledProcessError as e: self.logger.error(f"Flatpak override failed: {cmd_str}. Error: {e.stderr}") print(f"{COLOR_ERROR}Error applying override: {cmd_str}\n{e.stderr}{COLOR_RESET}") except Exception as e: self.logger.error(f"Unexpected error applying override {cmd_str}: {e}") print(f"{COLOR_ERROR}An unexpected error occurred: {e}{COLOR_RESET}") if success_count == len(override_commands): print(f"{COLOR_INFO}Successfully applied necessary Flatpak permissions.{COLOR_RESET}") else: print(f"{COLOR_ERROR}Applied {success_count}/{len(override_commands)} permissions. Some overrides may have failed. Check logs.{COLOR_RESET}") else: self.logger.info("User declined applying Flatpak overrides.") print("Permissions not applied. You may need to run the override command(s) manually") print("if Wabbajack has issues accessing files or game installations.") except KeyboardInterrupt: print("\nOperation cancelled by user.") self.logger.warning("User cancelled during Flatpak override prompt.") except Exception as e: self.logger.error(f"Error during Flatpak override prompt/execution: {e}") def _disable_prefix_decoration(self) -> bool: """Disables window manager decoration in the Wine prefix using protontricks -c.""" if not self.final_appid: self.logger.error("Cannot disable decoration: final_appid not set.") return False self.logger.info(f"Disabling window manager decoration for AppID {self.final_appid} via -c 'wine reg add...'") # Original command string command = 'wine reg add "HKCU\\Software\\Wine\\X11 Driver" /v Decorated /t REG_SZ /d N /f' try: # Ensure ProtontricksHandler is available if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler: self.logger.critical("ProtontricksHandler not initialized!") print(f"{COLOR_ERROR}Internal Error: Protontricks handler not available.{COLOR_RESET}") return False # Use the original -c method result = self.protontricks_handler.run_protontricks( '-c', command, self.final_appid # AppID comes last for -c commands ) # Check the return code if result and result.returncode == 0: self.logger.info("Successfully disabled window decoration (command returned 0).") # Add a small delay just in case there's a write lag? time.sleep(1) return True else: err_msg = result.stderr if result else "Command execution failed or returned non-zero" # Add stdout to error message if stderr is empty if result and not result.stderr and result.stdout: err_msg += f"\nSTDOUT: {result.stdout}" self.logger.error(f"Failed to disable window decoration via -c. Error: {err_msg}") print(f"{COLOR_ERROR}Error: Failed to disable window decoration via protontricks -c.{COLOR_RESET}") return False except Exception as e: self.logger.error(f"Exception disabling window decoration: {e}", exc_info=True) print(f"{COLOR_ERROR}Error disabling window decoration: {e}.{COLOR_RESET}") return False def _display_completion_message(self): """Displays the final success message and next steps.""" # Basic log file path (assuming standard location) # TODO: Get log file path more reliably if needed from jackify.shared.paths import get_jackify_logs_dir log_path = get_jackify_logs_dir() / "jackify-cli.log" print("\n───────────────────────────────────────────────────────────────────") print(f"{COLOR_INFO}Wabbajack Installation Completed Successfully!{COLOR_RESET}") print("───────────────────────────────────────────────────────────────────") print("Next Steps:") print(f" • Launch '{COLOR_INFO}{self.shortcut_name or 'Wabbajack'}{COLOR_RESET}' through Steam.") print(f" • When Wabbajack opens, log in to Nexus using the Settings button (cog icon).") print(f" • Once logged in, you can browse and install modlists as usual!") # Check for Flatpak Steam (Placeholder check) # A more robust check might involve inspecting self.path_handler findings or config # For now, check if compatdata path hints at flatpak is_flatpak_steam = False if self.compatdata_path and ".var/app/com.valvesoftware.Steam" in str(self.compatdata_path): is_flatpak_steam = True if is_flatpak_steam: self.logger.info("Detected Flatpak Steam usage.") print(f"\n{COLOR_PROMPT}Note: Flatpak Steam Detected:{COLOR_RESET}") print(f" You may need to grant Wabbajack filesystem access for modlist downloads/installations.") print(f" Example: If installing to \"/home/{os.getlogin()}/Games/SkyrimSEModlist\", run:") print(f" {COLOR_INFO}flatpak override --user --filesystem=/home/{os.getlogin()}/Games com.valvesoftware.Steam{COLOR_RESET}") print(f"\nDetailed log available at: {log_path}") print("───────────────────────────────────────────────────────────────────") def _download_wabbajack_executable(self) -> bool: """ Downloads the latest Wabbajack.exe to the install directory. Checks existence first. Returns: bool: True on success or if file exists, False otherwise. """ if not self.install_path: self.logger.error("Cannot download Wabbajack.exe: install_path is not set.") return False url = "https://github.com/wabbajack-tools/wabbajack/releases/latest/download/Wabbajack.exe" destination = self.install_path / "Wabbajack.exe" # Check if file exists first if destination.is_file(): self.logger.info(f"Wabbajack.exe already exists at {destination}. Skipping download.") # print("Wabbajack.exe already present.") # Replaced by logger return True # print(f"\nDownloading latest Wabbajack.exe...") # Replaced by logger self.logger.info("Wabbajack.exe not found. Downloading...") if self._download_file(url, destination): # print("Wabbajack.exe downloaded successfully.") # Replaced by logger # Set executable permissions try: os.chmod(destination, 0o755) self.logger.info(f"Set execute permissions on {destination}") except Exception as e: self.logger.warning(f"Could not set execute permission on {destination}: {e}") print(f"{COLOR_ERROR}Warning: Could not set execute permission on Wabbajack.exe.{COLOR_RESET}") return True else: self.logger.error("Failed to download Wabbajack.exe.") # Error message printed by _download_file return False def _create_steam_shortcut(self) -> bool: """ Creates the Steam shortcut for Wabbajack using the ShortcutHandler. Returns: bool: True on success, False otherwise. """ if not self.shortcut_name or not self.install_path: self.logger.error("Cannot create shortcut: Missing shortcut name or install path.") return False self.logger.info(f"Creating Steam shortcut '{self.shortcut_name}'...") executable_path = str(self.install_path / "Wabbajack.exe") # Ensure the ShortcutHandler instance exists # Create shortcut with working NativeSteamService from ..services.native_steam_service import NativeSteamService steam_service = NativeSteamService() success, app_id = steam_service.create_shortcut_with_proton( app_name=self.shortcut_name, exe_path=executable_path, start_dir=os.path.dirname(executable_path), launch_options="PROTON_USE_WINED3D=1 %command%", tags=["Jackify", "Wabbajack"], proton_version="proton_experimental" ) if success and app_id: self.initial_appid = app_id # Store the initially generated AppID self.logger.info(f"Shortcut created successfully with initial AppID: {self.initial_appid}") # Remove direct print, rely on status indicator from caller # print(f"Steam shortcut '{self.shortcut_name}' created.") return True else: self.logger.error("Failed to create Steam shortcut via ShortcutHandler.") print(f"{COLOR_ERROR}Error: Failed to create the Steam shortcut for Wabbajack.{COLOR_RESET}") # Further error details should be logged by the ShortcutHandler return False # --- Helper Methods for Workflow Steps --- def _display_manual_proton_steps(self): """Displays the detailed manual steps required for Proton setup.""" if not self.shortcut_name: self.logger.error("Cannot display manual steps: shortcut_name not set.") print(f"{COLOR_ERROR}Internal Error: Shortcut name missing.{COLOR_RESET}") return print(f"\n{COLOR_PROMPT}--- Manual Proton Setup Required ---{COLOR_RESET}") print("Please complete the following steps in Steam:") print(f" 1. Locate the '{COLOR_INFO}{self.shortcut_name}{COLOR_RESET}' entry in your Steam Library") print(" 2. Right-click and select 'Properties'") print(" 3. Switch to the 'Compatibility' tab") print(" 4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'") print(" 5. Select 'Proton - Experimental' from the dropdown menu") print(" 6. Close the Properties window") print(f" 7. Launch '{COLOR_INFO}{self.shortcut_name}{COLOR_RESET}' from your Steam Library") print(" 8. Wait for Wabbajack to download its files and fully load") print(" 9. Once Wabbajack has fully loaded, CLOSE IT completely and return here") print(f"{COLOR_PROMPT}------------------------------------{COLOR_RESET}") def _handle_steam_restart_and_manual_steps(self) -> bool: """ Handles Steam restart and manual steps prompt, but no extra confirmation. """ self.logger.info("Handling Steam restart and manual steps prompt.") clear_status() # Condensed message: only show essential manual steps guidance print("\n───────────────────────────────────────────────────────────────────") print(f"{COLOR_INFO}Manual Steps Required:{COLOR_RESET} After Steam restarts, follow the on-screen instructions to set Proton Experimental.") print("───────────────────────────────────────────────────────────────────") self.logger.info("Attempting secure Steam restart...") show_status("Restarting Steam") if not hasattr(self, 'shortcut_handler') or not self.shortcut_handler: self.logger.critical("ShortcutHandler not initialized in InstallWabbajackHandler!") print(f"{COLOR_ERROR}Internal Error: Shortcut handler not available for restart.{COLOR_RESET}") return False if self.shortcut_handler.secure_steam_restart(): self.logger.info("Secure Steam restart successful.") clear_status() self._display_manual_proton_steps() print() input(f"{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}") self.logger.info("User confirmed completion of manual steps.") return True else: self.logger.error("Secure Steam restart failed.") clear_status() print(f"\n{COLOR_ERROR}Error: Steam restart failed.{COLOR_RESET}") print("Please try restarting Steam manually:") print("1. Exit Steam completely (Steam -> Exit or right-click tray icon -> Exit)") print("2. Wait a few seconds") print("3. Start Steam again") print("\nAfter restarting, you MUST perform the manual Proton setup steps:") self._display_manual_proton_steps() print(f"\n{COLOR_ERROR}You will need to re-run this Jackify option after completing these steps.{COLOR_RESET}") print("───────────────────────────────────────────────────────────────────") return False def _redetect_appid(self) -> bool: """ Re-detects the AppID for the shortcut after Steam restart. Returns: bool: True if AppID is found, False otherwise. """ if not self.shortcut_name: self.logger.error("Cannot redetect AppID: shortcut_name not set.") return False self.logger.info(f"Re-detecting AppID for shortcut '{self.shortcut_name}'...") try: # Ensure the ProtontricksHandler instance exists if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler: self.logger.critical("ProtontricksHandler not initialized in InstallWabbajackHandler!") print(f"{COLOR_ERROR}Internal Error: Protontricks handler not available.{COLOR_RESET}") return False all_shortcuts = self.protontricks_handler.list_non_steam_shortcuts() if not all_shortcuts: self.logger.error("Protontricks listed no non-Steam shortcuts.") return False found_appid = None for name, appid in all_shortcuts.items(): if name.lower() == self.shortcut_name.lower(): found_appid = appid break if found_appid: self.final_appid = found_appid self.logger.info(f"Successfully re-detected AppID: {self.final_appid}") if self.initial_appid and self.initial_appid != self.final_appid: # Change Warning to Info - this is expected behavior self.logger.info(f"AppID changed after Steam restart: {self.initial_appid} -> {self.final_appid}") elif not self.initial_appid: self.logger.warning("Initial AppID was not set, cannot compare.") return True else: self.logger.error(f"Shortcut '{self.shortcut_name}' not found in protontricks list after restart.") return False except Exception as e: self.logger.error(f"Error re-detecting AppID: {e}", exc_info=True) return False def _find_steam_config_vdf(self) -> Optional[Path]: """Finds the path to the primary Steam config.vdf file.""" self.logger.debug("Searching for Steam config.vdf...") # Use PathHandler if it has this logic? For now, check common paths. common_paths = [ Path.home() / ".steam/steam/config/config.vdf", Path.home() / ".local/share/Steam/config/config.vdf", Path.home() / ".var/app/com.valvesoftware.Steam/.config/Valve Corporation/Steam/config/config.vdf" # Check Flatpak path ] for path in common_paths: if path.is_file(): self.logger.info(f"Found config.vdf at: {path}") return path self.logger.error("Could not find Steam config.vdf in common locations.") return None def _verify_manual_steps(self) -> bool: """ Verifies that the user has performed the manual steps using ModlistHandler. Checks AppID, Proton version set, and prefix existence. Returns: bool: True if verification passes AND compatdata_path is set, False otherwise. """ self.logger.info("Verifying manual Proton setup steps...") self.compatdata_path = None # Explicitly reset before verification # 1. Re-detect AppID # Clear status BEFORE potentially failing here clear_status() if not self._redetect_appid(): print(f"{COLOR_ERROR}Error: Could not find the Steam shortcut '{self.shortcut_name}' using protontricks.{COLOR_RESET}") print(f"{COLOR_INFO}Ensure Steam has restarted and the shortcut is visible.{COLOR_RESET}") return False # Indicate failure self.logger.debug(f"Verification using final AppID: {self.final_appid}") # Add padding after user confirmation before the next status update # Removed print() call - padding should come AFTER status clear # Print status JUST before calling the verification logic show_status("Verifying Proton Setup") # Ensure ModlistHandler is available if not hasattr(self, 'modlist_handler') or not self.modlist_handler: self.logger.critical("ModlistHandler not initialized in InstallWabbajackHandler!") print(f"{COLOR_ERROR}Internal Error: Modlist handler not available for verification.{COLOR_RESET}") return False # 2. Call the existing verification logic from ModlistHandler verified, status_code = self.modlist_handler.verify_proton_setup(self.final_appid) if not verified: # Handle Verification Failure Messages based on status_code if status_code == 'wrong_proton_version': proton_ver = getattr(self.modlist_handler, 'proton_ver', 'Unknown') print(f"{COLOR_ERROR}\nVerification Failed: Incorrect Proton version detected ('{proton_ver}'). Expected 'Proton Experimental' (or similar).{COLOR_RESET}") print(f"{COLOR_INFO}Please ensure you selected the correct Proton version in the shortcut's Compatibility properties.{COLOR_RESET}") elif status_code == 'proton_check_failed': print(f"{COLOR_ERROR}\nVerification Failed: Compatibility tool not detected as set for '{self.shortcut_name}' in Steam config.{COLOR_RESET}") print(f"{COLOR_INFO}Please ensure you forced a Proton version in the shortcut's Compatibility properties.{COLOR_RESET}") elif status_code == 'compatdata_missing': print(f"{COLOR_ERROR}\nVerification Failed: Steam compatdata directory for AppID {self.final_appid} not found.{COLOR_RESET}") print(f"{COLOR_INFO}Have you launched the shortcut '{self.shortcut_name}' at least once after setting Proton?{COLOR_RESET}") elif status_code == 'prefix_missing': print(f"{COLOR_ERROR}\nVerification Failed: Wine prefix directory (pfx) not found inside compatdata.{COLOR_RESET}") print(f"{COLOR_INFO}This usually means the shortcut hasn't been launched successfully after setting Proton.{COLOR_RESET}") elif status_code == 'config_vdf_missing' or status_code == 'config_vdf_error': print(f"{COLOR_ERROR}\nVerification Failed: Could not read or parse Steam's config.vdf file ({status_code}).{COLOR_RESET}") print(f"{COLOR_INFO}Check file permissions or integrity. Check logs for details.{COLOR_RESET}") else: # General/unknown error print(f"{COLOR_ERROR}\nVerification Failed: An unexpected error occurred ({status_code}). Check logs.{COLOR_RESET}") return False # Indicate verification failure # If we reach here, basic verification passed (proton set, prefix exists) # Now, ensure we have the compatdata path. self.logger.info("Basic verification checks passed. Confirming compatdata path...") modlist_handler_compat_path = getattr(self.modlist_handler, 'compat_data_path', None) if modlist_handler_compat_path: self.compatdata_path = modlist_handler_compat_path self.logger.info(f"Compatdata path obtained from ModlistHandler: {self.compatdata_path}") else: # If modlist_handler didn't set it, try path_handler # Change Warning to Info - Fallback is acceptable self.logger.info("ModlistHandler did not set compat_data_path. Attempting manual lookup via PathHandler.") # Ensure path_handler is available if not hasattr(self, 'path_handler') or not self.path_handler: self.logger.critical("PathHandler not initialized in InstallWabbajackHandler!") print(f"{COLOR_ERROR}Internal Error: Path handler not available for verification.{COLOR_RESET}") return False self.compatdata_path = self.path_handler.find_compat_data(self.final_appid) if self.compatdata_path: self.logger.info(f"Manually found compatdata path via PathHandler: {self.compatdata_path}") else: self.logger.error("Verification checks passed, but COULD NOT FIND compatdata path via ModlistHandler or PathHandler.") print(f"{COLOR_ERROR}\nVerification Error: Basic checks passed, but failed to locate the compatdata directory for AppID {self.final_appid}.{COLOR_RESET}") print(f"{COLOR_INFO}This is unexpected. Check Steam filesystem structure and logs.{COLOR_RESET}") return False # CRITICAL: Return False if path is unobtainable # If we get here, verification passed AND we have the compatdata_path self.logger.info("Manual steps verification successful (including path confirmation).") logger.info(f"Verification successful! (AppID: {self.final_appid}, Path: {self.compatdata_path})") return True def _download_webview_installer(self) -> bool: """ Downloads the specific WebView2 installer needed by Wabbajack. Checks existence first. Returns: bool: True on success or if file already exists correctly, False otherwise. """ if not self.install_path: self.logger.error("Cannot download WebView installer: install_path is not set.") return False url = "https://node10.sokloud.com/filebrowser/api/public/dl/yqVTbUT8/rwatch/WebView/MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" file_name = "MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" destination = self.install_path / file_name self.logger.info(f"Checking WebView installer: {destination}") # print(f"\nChecking required WebView installer ({file_name})...") # Replaced by logger if destination.is_file(): self.logger.info(f"WebView installer {destination.name} already exists. Skipping download.") # Consider adding a message here if verbose/debug? return True # File doesn't exist, attempt download self.logger.info(f"WebView installer not found locally. Downloading {file_name}...") # Update status before starting download - Use a more user-friendly message show_status("Downloading WebView Installer") if self._download_file(url, destination): # Status will be cleared by caller or next step return True else: self.logger.error(f"Failed to download WebView installer from {url}.") # Error message already printed by _download_file return False def _set_prefix_renderer(self, renderer: str = 'vulkan') -> bool: """Sets the prefix renderer using protontricks.""" if not self.final_appid: self.logger.error("Cannot set renderer: final_appid not set.") return False self.logger.info(f"Setting prefix renderer to {renderer} for AppID {self.final_appid}...") try: # Ensure the ProtontricksHandler instance exists if not hasattr(self, 'protontricks_handler') or not self.protontricks_handler: self.logger.critical("ProtontricksHandler not initialized in InstallWabbajackHandler!") print(f"{COLOR_ERROR}Internal Error: Protontricks handler not available.{COLOR_RESET}") return False result = self.protontricks_handler.run_protontricks( self.final_appid, 'settings', f'renderer={renderer}' ) if result and result.returncode == 0: self.logger.info(f"Successfully set renderer to {renderer}.") return True else: err_msg = result.stderr if result else "Command execution failed" self.logger.error(f"Failed to set renderer to {renderer}. Error: {err_msg}") print(f"{COLOR_ERROR}Error: Failed to set prefix renderer to {renderer}.{COLOR_RESET}") return False except Exception as e: self.logger.error(f"Exception setting renderer: {e}", exc_info=True) print(f"{COLOR_ERROR}Error setting prefix renderer: {e}.{COLOR_RESET}") return False def _download_and_replace_reg_file(self, url: str, target_reg_path: Path) -> bool: """Downloads a .reg file and replaces the target file. Always downloads and overwrites. """ self.logger.info(f"Downloading registry file from {url} to replace {target_reg_path}") # Always download and replace for registry files if self._download_file(url, target_reg_path): self.logger.info(f"Successfully downloaded and replaced {target_reg_path}") return True else: self.logger.error(f"Failed to download/replace {target_reg_path} from {url}") return False # Example usage (for testing - keep this section for easy module testing) if __name__ == '__main__': # Configure logging for standalone testing logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') print("Testing Wabbajack Install Handler...") # Simulate running on or off deck test_on_deck = False print(f"Simulating run with steamdeck={test_on_deck}") # Need dummy handlers for direct testing class DummyProton: which_protontricks = 'native' def check_and_setup_protontricks(self): return True def set_protontricks_permissions(self, path, steamdeck): return True def enable_dotfiles(self, appid): return True def _cleanup_wine_processes(self): pass def run_protontricks(self, *args, **kwargs): return subprocess.CompletedProcess(args=[], returncode=0) def list_non_steam_shortcuts(self): return {"Wabbajack": "12345"} class DummyShortcut: def create_shortcut(self, *args, **kwargs): return True, "12345" def secure_steam_restart(self): return True class DummyPath: def find_compat_data(self, appid): return Path(f"/tmp/test_compat/{appid}") def find_steam_library(self): return Path("/tmp/test_steam/steamapps/common") class DummyVDF: @staticmethod def load(path): if "config.vdf" in str(path): # Simulate structure needed for proton check return {'UserLocalConfigStore': {'Software': {'Valve': {'Steam': {'apps': {'12345': {'CompatTool': 'proton_experimental'}}}}}}} return {} handler = InstallWabbajackHandler( steamdeck=test_on_deck, protontricks_handler=DummyProton(), shortcut_handler=DummyShortcut(), path_handler=DummyPath(), vdf_handler=DummyVDF(), modlist_handler=ModlistHandler(), filesystem_handler=FileSystemHandler() ) # Pre-create dummy compatdata dir for verification step if not Path("/tmp/test_compat/12345/pfx").exists(): os.makedirs("/tmp/test_compat/12345/pfx", exist_ok=True) handler.run_install_workflow() print("\nTesting completed.")