Files
Jackify/jackify/backend/handlers/wabbajack_installer_handler.py
2026-01-12 22:15:19 +00:00

602 lines
20 KiB
Python

"""
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/<userid> 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: <install_path>/<home_path>/.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)