Files
Jackify/jackify/backend/handlers/install_wabbajack_handler.py

1664 lines
89 KiB
Python

#!/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, with GUI mode support.
"""
self.logger.info("Handling Steam restart and manual steps prompt.")
clear_status()
if os.environ.get('JACKIFY_GUI_MODE'):
# GUI mode: emit prompt markers like ModlistMenuHandler does
print('[PROMPT:RESTART_STEAM]')
input() # Wait for GUI to send confirmation
print('[PROMPT:MANUAL_STEPS]')
input() # Wait for GUI to send confirmation
# Continue with verification as before
return True
else:
# CLI mode: original behavior
# 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/jackify_test/compatdata/{appid}")
def find_steam_library(self): return Path("/tmp/jackify_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/jackify_test/compatdata/12345/pfx").exists():
os.makedirs("/tmp/jackify_test/compatdata/12345/pfx", exist_ok=True)
handler.run_install_workflow()
print("\nTesting completed.")