""" Wabbajack Installer Handler Automated Wabbajack.exe installation and configuration via Proton. Self-contained implementation inspired by Wabbajack-Proton-AuCu (MIT). This handler provides: - Automatic Wabbajack.exe download - Steam shortcuts.vdf manipulation - WebView2 installation - Win7 registry configuration - Optional Heroic GOG game detection """ import json import logging import os import shutil import subprocess import tempfile import urllib.request import zlib from pathlib import Path from typing import Optional, List, Dict, Tuple try: import vdf except ImportError: vdf = None class WabbajackInstallerHandler: """Handles automated Wabbajack installation via Proton""" # Download URLs WABBAJACK_URL = "https://github.com/wabbajack-tools/wabbajack/releases/latest/download/Wabbajack.exe" WEBVIEW2_URL = "https://files.omnigaming.org/MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe" # Minimal Win7 registry settings for Wabbajack compatibility WIN7_REGISTRY = """REGEDIT4 [HKEY_LOCAL_MACHINE\\Software\\Microsoft\\Windows NT\\CurrentVersion] "ProductName"="Microsoft Windows 7" "CSDVersion"="Service Pack 1" "CurrentBuild"="7601" "CurrentBuildNumber"="7601" "CurrentVersion"="6.1" [HKEY_LOCAL_MACHINE\\System\\CurrentControlSet\\Control\\Windows] "CSDVersion"=dword:00000100 [HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\Wabbajack.exe\\X11 Driver] "Decorated"="N" """ def __init__(self): self.logger = logging.getLogger(__name__) def calculate_app_id(self, exe_path: str, app_name: str) -> int: """ Calculate Steam AppID using CRC32 algorithm. Args: exe_path: Path to executable (must be quoted) app_name: Application name Returns: AppID (31-bit to fit signed 32-bit integer range for VDF binary format) """ input_str = f"{exe_path}{app_name}" crc = zlib.crc32(input_str.encode()) & 0x7FFFFFFF # Use 31 bits for signed int return crc def find_steam_userdata_path(self) -> Optional[Path]: """ Find most recently used Steam userdata directory. Returns: Path to userdata/ or None if not found """ home = Path.home() steam_paths = [ home / ".steam/steam", home / ".local/share/Steam", home / ".var/app/com.valvesoftware.Steam/.local/share/Steam", ] for steam_path in steam_paths: userdata = steam_path / "userdata" if not userdata.exists(): continue # Find most recently modified numeric user directory user_dirs = [] for entry in userdata.iterdir(): if entry.is_dir() and entry.name.isdigit(): user_dirs.append(entry) if user_dirs: # Sort by modification time (most recent first) user_dirs.sort(key=lambda p: p.stat().st_mtime, reverse=True) self.logger.info(f"Found Steam userdata: {user_dirs[0]}") return user_dirs[0] return None def get_shortcuts_vdf_path(self) -> Optional[Path]: """Get path to shortcuts.vdf file""" userdata = self.find_steam_userdata_path() if userdata: return userdata / "config/shortcuts.vdf" return None def add_to_steam_shortcuts(self, exe_path: Path) -> int: """ Add Wabbajack to Steam shortcuts.vdf and return calculated AppID. Args: exe_path: Path to Wabbajack.exe Returns: Calculated AppID Raises: RuntimeError: If vdf library not available or shortcuts.vdf not found """ if vdf is None: raise RuntimeError("vdf library not installed. Install with: pip install vdf") shortcuts_path = self.get_shortcuts_vdf_path() if not shortcuts_path: raise RuntimeError("Could not find Steam shortcuts.vdf path") self.logger.info(f"Shortcuts.vdf path: {shortcuts_path}") # Read existing shortcuts or create new if shortcuts_path.exists(): with open(shortcuts_path, 'rb') as f: shortcuts = vdf.binary_load(f) else: shortcuts = {'shortcuts': {}} # Ensure parent directory exists shortcuts_path.parent.mkdir(parents=True, exist_ok=True) # Calculate AppID exe_str = f'"{str(exe_path)}"' app_id = self.calculate_app_id(exe_str, "Wabbajack") self.logger.info(f"Calculated AppID: {app_id}") # Create shortcut entry idx = str(len(shortcuts.get('shortcuts', {}))) shortcuts.setdefault('shortcuts', {})[idx] = { 'appid': app_id, 'AppName': 'Wabbajack', 'Exe': exe_str, 'StartDir': f'"{str(exe_path.parent)}"', 'icon': str(exe_path), 'ShortcutPath': '', 'LaunchOptions': '', 'IsHidden': 0, 'AllowDesktopConfig': 1, 'AllowOverlay': 1, 'OpenVR': 0, 'Devkit': 0, 'DevkitGameID': '', 'DevkitOverrideAppID': 0, 'LastPlayTime': 0, 'FlatpakAppID': '', 'tags': {} } # Write back (binary format) with open(shortcuts_path, 'wb') as f: vdf.binary_dump(shortcuts, f) self.logger.info(f"Added Wabbajack to Steam shortcuts with AppID {app_id}") return app_id def create_dotnet_cache(self, install_folder: Path): """ Create .NET bundle extract cache directory. Wabbajack requires: //.cache/dotnet_bundle_extract Args: install_folder: Wabbajack installation directory """ home = Path.home() # Strip leading slash to make it relative home_relative = str(home).lstrip('/') cache_dir = install_folder / home_relative / '.cache/dotnet_bundle_extract' cache_dir.mkdir(parents=True, exist_ok=True) self.logger.info(f"Created dotnet cache: {cache_dir}") def download_file(self, url: str, dest: Path, description: str = "file") -> None: """ Download file with progress logging. Args: url: Download URL dest: Destination path description: Description for logging Raises: RuntimeError: If download fails """ self.logger.info(f"Downloading {description} from {url}") try: # Ensure parent directory exists dest.parent.mkdir(parents=True, exist_ok=True) # Download with user agent request = urllib.request.Request( url, headers={'User-Agent': 'Jackify-WabbajackInstaller'} ) with urllib.request.urlopen(request) as response: with open(dest, 'wb') as f: shutil.copyfileobj(response, f) self.logger.info(f"Downloaded {description} to {dest}") except Exception as e: raise RuntimeError(f"Failed to download {description}: {e}") def download_wabbajack(self, install_folder: Path) -> Path: """ Download Wabbajack.exe to installation folder. Args: install_folder: Installation directory Returns: Path to downloaded Wabbajack.exe """ install_folder.mkdir(parents=True, exist_ok=True) wabbajack_exe = install_folder / "Wabbajack.exe" # Skip if already exists if wabbajack_exe.exists(): self.logger.info(f"Wabbajack.exe already exists at {wabbajack_exe}") return wabbajack_exe self.download_file(self.WABBAJACK_URL, wabbajack_exe, "Wabbajack.exe") return wabbajack_exe def find_proton_experimental(self) -> Optional[Path]: """ Find Proton Experimental installation path. Returns: Path to Proton Experimental directory or None """ home = Path.home() steam_paths = [ home / ".steam/steam", home / ".local/share/Steam", home / ".var/app/com.valvesoftware.Steam/.local/share/Steam", ] for steam_path in steam_paths: proton_path = steam_path / "steamapps/common/Proton - Experimental" if proton_path.exists(): self.logger.info(f"Found Proton Experimental: {proton_path}") return proton_path return None def get_compat_data_path(self, app_id: int) -> Optional[Path]: """Get compatdata path for AppID""" home = Path.home() steam_paths = [ home / ".steam/steam", home / ".local/share/Steam", home / ".var/app/com.valvesoftware.Steam/.local/share/Steam", ] for steam_path in steam_paths: compat_path = steam_path / f"steamapps/compatdata/{app_id}" if compat_path.parent.exists(): # Parent exists, so this is valid location even if prefix doesn't exist yet return compat_path return None def init_wine_prefix(self, app_id: int) -> Path: """ Initialize Wine prefix using Proton. Args: app_id: Steam AppID Returns: Path to created prefix Raises: RuntimeError: If prefix creation fails """ proton_path = self.find_proton_experimental() if not proton_path: raise RuntimeError("Proton Experimental not found. Please install it from Steam.") compat_data = self.get_compat_data_path(app_id) if not compat_data: raise RuntimeError("Could not determine compatdata path") prefix_path = compat_data / "pfx" # Create compat data directory compat_data.mkdir(parents=True, exist_ok=True) # Run wineboot to initialize prefix proton_bin = proton_path / "proton" env = os.environ.copy() env['STEAM_COMPAT_DATA_PATH'] = str(compat_data) env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(compat_data.parent.parent.parent) self.logger.info(f"Initializing Wine prefix for AppID {app_id}...") result = subprocess.run( [str(proton_bin), 'run', 'wineboot'], env=env, capture_output=True, text=True, timeout=120 ) if result.returncode != 0: raise RuntimeError(f"Failed to initialize Wine prefix: {result.stderr}") self.logger.info(f"Prefix created: {prefix_path}") return prefix_path def run_in_prefix(self, app_id: int, exe_path: Path, args: List[str] = None) -> None: """ Run executable in Wine prefix using Proton. Args: app_id: Steam AppID exe_path: Path to executable args: Optional command line arguments Raises: RuntimeError: If execution fails """ proton_path = self.find_proton_experimental() if not proton_path: raise RuntimeError("Proton Experimental not found") compat_data = self.get_compat_data_path(app_id) if not compat_data: raise RuntimeError("Could not determine compatdata path") proton_bin = proton_path / "proton" cmd = [str(proton_bin), 'run', str(exe_path)] if args: cmd.extend(args) env = os.environ.copy() env['STEAM_COMPAT_DATA_PATH'] = str(compat_data) env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(compat_data.parent.parent.parent) self.logger.info(f"Running {exe_path.name} in prefix...") result = subprocess.run( cmd, env=env, capture_output=True, text=True, timeout=300 ) if result.returncode != 0: error_msg = f"Failed to run {exe_path.name} (exit code {result.returncode})" if result.stderr: error_msg += f"\nStderr: {result.stderr}" if result.stdout: error_msg += f"\nStdout: {result.stdout}" self.logger.error(error_msg) raise RuntimeError(error_msg) def apply_registry(self, app_id: int, reg_content: str) -> None: """ Apply registry content to Wine prefix. Args: app_id: Steam AppID reg_content: Registry file content Raises: RuntimeError: If registry application fails """ proton_path = self.find_proton_experimental() if not proton_path: raise RuntimeError("Proton Experimental not found") compat_data = self.get_compat_data_path(app_id) if not compat_data: raise RuntimeError("Could not determine compatdata path") prefix_path = compat_data / "pfx" if not prefix_path.exists(): raise RuntimeError(f"Prefix not found: {prefix_path}") # Write registry content to temp file with tempfile.NamedTemporaryFile(mode='w', suffix='.reg', delete=False) as f: f.write(reg_content) temp_reg = Path(f.name) try: # Use Proton's wine directly wine_bin = proton_path / "files/bin/wine64" self.logger.info("Applying registry settings...") env = os.environ.copy() env['WINEPREFIX'] = str(prefix_path) result = subprocess.run( [str(wine_bin), 'regedit', str(temp_reg)], env=env, capture_output=True, text=True, timeout=30 ) if result.returncode != 0: raise RuntimeError(f"Failed to apply registry: {result.stderr}") self.logger.info("Registry settings applied") finally: # Cleanup temp file if temp_reg.exists(): temp_reg.unlink() def install_webview2(self, app_id: int, install_folder: Path) -> None: """ Download and install WebView2 runtime. Args: app_id: Steam AppID install_folder: Directory to download installer to Raises: RuntimeError: If installation fails """ webview_installer = install_folder / "webview2_installer.exe" # Download installer self.download_file(self.WEBVIEW2_URL, webview_installer, "WebView2 installer") try: # Run installer with silent flags self.logger.info("Installing WebView2 (this may take a minute)...") self.logger.info(f"WebView2 installer path: {webview_installer}") self.logger.info(f"AppID: {app_id}") try: self.run_in_prefix(app_id, webview_installer, ["/silent", "/install"]) self.logger.info("WebView2 installed successfully") except RuntimeError as e: self.logger.error(f"WebView2 installation failed: {e}") # Re-raise to let caller handle it raise finally: # Cleanup installer if webview_installer.exists(): try: webview_installer.unlink() self.logger.debug("Cleaned up WebView2 installer") except Exception as e: self.logger.warning(f"Failed to cleanup WebView2 installer: {e}") def apply_win7_registry(self, app_id: int) -> None: """ Apply Windows 7 registry settings. Args: app_id: Steam AppID Raises: RuntimeError: If registry application fails """ self.apply_registry(app_id, self.WIN7_REGISTRY) def detect_heroic_gog_games(self) -> List[Dict]: """ Detect GOG games installed via Heroic Games Launcher. Returns: List of dicts with keys: app_name, title, install_path, build_id """ heroic_paths = [ Path.home() / ".config/heroic", Path.home() / ".var/app/com.heroicgameslauncher.hgl/config/heroic" ] for heroic_path in heroic_paths: if not heroic_path.exists(): continue installed_json = heroic_path / "gog_store/installed.json" if not installed_json.exists(): continue try: # Read installed games with open(installed_json) as f: data = json.load(f) installed = data.get('installed', []) # Read library for titles library_json = heroic_path / "store_cache/gog_library.json" titles = {} if library_json.exists(): with open(library_json) as f: lib = json.load(f) titles = {g['app_name']: g['title'] for g in lib.get('games', [])} # Build game list games = [] for game in installed: app_name = game.get('appName') if not app_name: continue games.append({ 'app_name': app_name, 'title': titles.get(app_name, f"GOG Game {app_name}"), 'install_path': game.get('install_path', ''), 'build_id': game.get('buildId', '') }) if games: self.logger.info(f"Found {len(games)} GOG games from Heroic") for game in games: self.logger.debug(f" - {game['title']} ({game['app_name']})") return games except Exception as e: self.logger.warning(f"Failed to read Heroic config: {e}") continue return [] def generate_gog_registry(self, games: List[Dict]) -> str: """ Generate registry file content for GOG games. Args: games: List of GOG game dicts from detect_heroic_gog_games() Returns: Registry file content """ reg = "REGEDIT4\n\n" reg += "[HKEY_LOCAL_MACHINE\\Software\\GOG.com]\n\n" reg += "[HKEY_LOCAL_MACHINE\\Software\\GOG.com\\Games]\n\n" reg += "[HKEY_LOCAL_MACHINE\\Software\\WOW6432Node\\GOG.com]\n\n" reg += "[HKEY_LOCAL_MACHINE\\Software\\WOW6432Node\\GOG.com\\Games]\n\n" for game in games: # Convert Linux path to Wine Z: drive linux_path = game['install_path'] wine_path = f"Z:{linux_path}".replace('/', '\\\\') # Add to both 32-bit and 64-bit registry locations for prefix in ['Software\\GOG.com\\Games', 'Software\\WOW6432Node\\GOG.com\\Games']: reg += f"[HKEY_LOCAL_MACHINE\\{prefix}\\{game['app_name']}]\n" reg += f'"path"="{wine_path}"\n' reg += f'"gameID"="{game["app_name"]}"\n' reg += f'"gameName"="{game["title"]}"\n' reg += f'"buildId"="{game["build_id"]}"\n' reg += f'"workingDir"="{wine_path}"\n\n' return reg def inject_gog_registry(self, app_id: int) -> int: """ Inject Heroic GOG games into Wine prefix registry. Args: app_id: Steam AppID Returns: Number of games injected """ games = self.detect_heroic_gog_games() if not games: self.logger.info("No GOG games found in Heroic") return 0 reg_content = self.generate_gog_registry(games) self.logger.info(f"Injecting {len(games)} GOG games into prefix...") self.apply_registry(app_id, reg_content) self.logger.info(f"Injected {len(games)} GOG games") return len(games)