Sync from development - prepare for v0.2.1

This commit is contained in:
Omni
2026-01-12 22:15:19 +00:00
parent 9b5310c2f9
commit 29e1800074
75 changed files with 3007 additions and 523 deletions

View File

@@ -1,5 +1,29 @@
# Jackify Changelog
## v0.2.1 - Wabbajack Installer and ENB Support
**Release Date:** 2025-01-12
### Major Features
- **Automated Wabbajack Installation**: While I work on Non-Premium support, there is still a call for Wabbajack via Proton. The existing legacy bash script has been proving troublesome for some users, so I've added this as a new feature within Jackify. My aim is still to not need this in future, once Jackify can cover Non-Premium accounts.
- **ENB Detection and Configuration**: Automatic detection and configuration of `enblocal.ini` with `LinuxVersion=true` for all supported games
- **ENB Proton Warning**: Dedicated dialog with Proton version recommendations when ENB is detected
### Critical Bug Fixes
- **OAuth Token Stale State**: Re-check authentication before engine launch to prevent stale token errors after revocation
- **FNV SD Card Registry**: Fixed launcher not recognizing game on SD cards (uses `D:` drive for SD, `Z:` for internal)
- **CLI FILE_PROGRESS Spam**: Filter verbose output to preserve single-line progress updates
- **Steam Double Restart**: Removed legacy code causing double restart during configuration
- **TTW Installer lz4**: Fixed bundled lz4 detection by setting correct working directory
### Improvements
- **Winetricks Bundling**: Bundled critical dependencies (wget, sha256sum, unzip, 7z) for improved reliability
- **UI/UX**: Removed per-file download speeds to match Wabbajack upstream
- **Code Cleanup**: Removed PyInstaller references, use AppImage detection only
- **Wabbajack Installer UI**: Removed unused Process Monitor tab, improved Activity window with detailed step information
- **Steam AppID Overflow Fix**: Changed AppID handling to string type to prevent overflow errors with large Steam AppIDs
---
## v0.2.0.10 - Registry & Hashing Fixes
**Release Date:** 2025-01-04

View File

@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
Wabbajack modlists natively on Linux systems.
"""
__version__ = "0.2.0.10"
__version__ = "0.2.1"

View File

@@ -92,15 +92,16 @@ def get_jackify_engine_path():
logger.debug(f"Using engine from environment variable: {env_engine_path}")
return env_engine_path
# Priority 2: Frozen bundle (most specific detection)
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
# Running inside a frozen bundle
# Engine is expected at <bundle_root>/jackify/engine/jackify-engine
engine_path = os.path.join(sys._MEIPASS, 'jackify', 'engine', 'jackify-engine')
# Priority 2: AppImage bundle (most specific detection)
appdir = os.environ.get('APPDIR')
if appdir:
# Running inside AppImage
# Engine is expected at <appdir>/opt/jackify/engine/jackify-engine
engine_path = os.path.join(appdir, 'opt', 'jackify', 'engine', 'jackify-engine')
if os.path.exists(engine_path):
return engine_path
# Fallback: log warning but continue to other detection methods
logger.warning(f"Frozen-bundle engine not found at expected path: {engine_path}")
logger.warning(f"AppImage engine not found at expected path: {engine_path}")
# Priority 3: Check if THIS process is actually running from Jackify AppImage
# (not just inheriting APPDIR from another AppImage like Cursor)
@@ -125,7 +126,6 @@ def get_jackify_engine_path():
# If all else fails, log error and return the source path anyway
logger.error(f"jackify-engine not found in any expected location. Tried:")
logger.error(f" Frozen bundle: {getattr(sys, '_MEIPASS', 'N/A')}/jackify/engine/jackify-engine")
logger.error(f" AppImage: {appdir or 'N/A'}/opt/jackify/engine/jackify-engine")
logger.error(f" Source: {engine_path}")
logger.error("This will likely cause installation failures.")
@@ -739,8 +739,17 @@ class ModlistInstallCLI:
modlist_arg = self.context.get('modlist_value') or self.context.get('machineid')
machineid = self.context.get('machineid')
api_key = self.context.get('nexus_api_key')
oauth_info = self.context.get('nexus_oauth_info')
# CRITICAL: Re-check authentication right before launching engine
# This ensures we use current auth state, not stale cached values from context
# (e.g., if user revoked OAuth after context was created)
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
current_api_key, current_oauth_info = auth_service.get_auth_for_engine()
# Use current auth state, fallback to context values only if current check failed
api_key = current_api_key or self.context.get('nexus_api_key')
oauth_info = current_oauth_info or self.context.get('nexus_oauth_info')
# Path to the engine binary
engine_path = get_jackify_engine_path()
@@ -791,7 +800,11 @@ class ModlistInstallCLI:
# Prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY (legacy)
if oauth_info:
os.environ['NEXUS_OAUTH_INFO'] = oauth_info
self.logger.debug(f"Set NEXUS_OAUTH_INFO for engine (supports auto-refresh)")
# CRITICAL: Set client_id so engine can refresh tokens with correct client_id
# Engine's RefreshToken method reads this to use our "jackify" client_id instead of hardcoded "wabbajack"
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
self.logger.debug(f"Set NEXUS_OAUTH_INFO and NEXUS_OAUTH_CLIENT_ID={NexusOAuthService.CLIENT_ID} for engine (supports auto-refresh)")
# Also set NEXUS_API_KEY for backward compatibility
if api_key:
os.environ['NEXUS_API_KEY'] = api_key
@@ -805,6 +818,8 @@ class ModlistInstallCLI:
del os.environ['NEXUS_API_KEY']
if 'NEXUS_OAUTH_INFO' in os.environ:
del os.environ['NEXUS_OAUTH_INFO']
if 'NEXUS_OAUTH_CLIENT_ID' in os.environ:
del os.environ['NEXUS_OAUTH_CLIENT_ID']
self.logger.debug(f"No Nexus auth available, cleared inherited env vars")
os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1"
@@ -844,11 +859,29 @@ class ModlistInstallCLI:
if chunk == b'\n':
# Complete line - decode and print
line = buffer.decode('utf-8', errors='replace')
# Filter FILE_PROGRESS spam but keep the status line before it
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
# Skip this line entirely if it's only FILE_PROGRESS
buffer = b''
continue
print(line, end='')
buffer = b''
elif chunk == b'\r':
# Carriage return - decode and print without newline
line = buffer.decode('utf-8', errors='replace')
# Filter FILE_PROGRESS spam but keep the status line before it
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
# Skip this line entirely if it's only FILE_PROGRESS
buffer = b''
continue
print(line, end='')
sys.stdout.flush()
buffer = b''
@@ -856,7 +889,16 @@ class ModlistInstallCLI:
# Print any remaining buffer content
if buffer:
line = buffer.decode('utf-8', errors='replace')
print(line, end='')
# Filter FILE_PROGRESS spam but keep the status line before it
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
# Skip this line entirely if it's only FILE_PROGRESS
line = ''
if line:
print(line, end='')
proc.wait()
# Clear process reference after completion

View File

@@ -0,0 +1,317 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ENB Handler Module
Handles ENB detection and Linux compatibility configuration for modlists.
"""
import logging
import configparser
import shutil
from pathlib import Path
from typing import Dict, Any, Optional, Tuple
logger = logging.getLogger(__name__)
class ENBHandler:
"""
Handles ENB detection and configuration for Linux compatibility.
Detects ENB components in modlist installations and ensures enblocal.ini
has the required LinuxVersion=true setting in the [GLOBAL] section.
"""
def __init__(self):
"""Initialize ENB handler."""
self.logger = logger
def detect_enb_in_modlist(self, modlist_path: Path) -> Dict[str, Any]:
"""
Detect ENB components in modlist installation.
Searches for ENB configuration files:
- enbseries.ini, enblocal.ini (ENB configuration files)
Note: Does NOT check for DLL files (d3d9.dll, d3d11.dll, dxgi.dll) as these
are used by many other mods (ReShade, other graphics mods) and are not
reliable indicators of ENB presence.
Args:
modlist_path: Path to modlist installation directory
Returns:
Dict with detection results:
- has_enb: bool - True if ENB config files found
- enblocal_ini: str or None - Path to enblocal.ini if found
- enbseries_ini: str or None - Path to enbseries.ini if found
- d3d9_dll: str or None - Always None (not checked)
- d3d11_dll: str or None - Always None (not checked)
- dxgi_dll: str or None - Always None (not checked)
"""
enb_info = {
'has_enb': False,
'enblocal_ini': None,
'enbseries_ini': None,
'd3d9_dll': None,
'd3d11_dll': None,
'dxgi_dll': None
}
if not modlist_path.exists():
self.logger.warning(f"Modlist path does not exist: {modlist_path}")
return enb_info
# Search for ENB indicator files
# IMPORTANT: Only check for ENB config files (enbseries.ini, enblocal.ini)
# Do NOT check for DLL files (d3d9.dll, d3d11.dll, dxgi.dll) as these are used
# by many other mods (ReShade, other graphics mods) and are not reliable ENB indicators
enb_config_patterns = [
('**/enbseries.ini', 'enbseries_ini'),
('**/enblocal.ini', 'enblocal_ini')
]
for pattern, key in enb_config_patterns:
for file_path in modlist_path.glob(pattern):
# Skip backups and plugin data directories
if "Backup" in str(file_path) or "plugins/data" in str(file_path):
continue
enb_info['has_enb'] = True
if not enb_info[key]: # Store first match
enb_info[key] = str(file_path)
# If we detected ENB config but didn't find enblocal.ini via glob,
# use the priority-based finder
if enb_info['has_enb'] and not enb_info['enblocal_ini']:
found_ini = self.find_enblocal_ini(modlist_path)
if found_ini:
enb_info['enblocal_ini'] = str(found_ini)
return enb_info
def find_enblocal_ini(self, modlist_path: Path) -> Optional[Path]:
"""
Find enblocal.ini in modlist installation using priority-based search.
Search order (highest priority first):
1. Stock Game/Game Root directories (active locations)
2. Mods folder with Root/root subfolder (most common pattern)
3. Direct in mods/fixes folders
4. Fallback recursive search (excluding backups)
Args:
modlist_path: Path to modlist installation directory
Returns:
Path to enblocal.ini if found, None otherwise
"""
if not modlist_path.exists():
return None
# Priority 1: Stock Game/Game Root (active locations)
stock_game_names = [
"Stock Game",
"Game Root",
"STOCK GAME",
"Stock Game Folder",
"Stock Folder",
"Skyrim Stock"
]
for name in stock_game_names:
candidate = modlist_path / name / "enblocal.ini"
if candidate.exists():
self.logger.debug(f"Found enblocal.ini in Stock Game location: {candidate}")
return candidate
# Priority 2: Mods folder with Root/root subfolder
mods_dir = modlist_path / "mods"
if mods_dir.exists():
# Search for Root/root subfolders
for root_dir in mods_dir.rglob("Root"):
candidate = root_dir / "enblocal.ini"
if candidate.exists():
self.logger.debug(f"Found enblocal.ini in mods/Root: {candidate}")
return candidate
for root_dir in mods_dir.rglob("root"):
candidate = root_dir / "enblocal.ini"
if candidate.exists():
self.logger.debug(f"Found enblocal.ini in mods/root: {candidate}")
return candidate
# Priority 3: Direct in mods/fixes folders
for search_dir in [modlist_path / "mods", modlist_path / "fixes"]:
if search_dir.exists():
for enb_file in search_dir.rglob("enblocal.ini"):
# Skip backups and plugin data
if "Backup" not in str(enb_file) and "plugins/data" not in str(enb_file):
self.logger.debug(f"Found enblocal.ini in {search_dir.name}: {enb_file}")
return enb_file
# Priority 4: Fallback recursive search (exclude backups)
for enb_file in modlist_path.rglob("enblocal.ini"):
if "Backup" not in str(enb_file) and "plugins/data" not in str(enb_file):
self.logger.debug(f"Found enblocal.ini via recursive search: {enb_file}")
return enb_file
return None
def ensure_linux_version_setting(self, enblocal_ini_path: Path) -> bool:
"""
Safely ensure [GLOBAL] section exists with LinuxVersion=true in enblocal.ini.
Safety features:
- Verifies file exists before attempting modification
- Checks if [GLOBAL] section exists before adding (prevents duplicates)
- Creates backup before any write operation
- Only writes if changes are actually needed
- Handles encoding issues gracefully
- Preserves existing file structure and comments
Args:
enblocal_ini_path: Path to enblocal.ini file
Returns:
bool: True if successful or no changes needed, False on error
"""
try:
# Safety check: file must exist
if not enblocal_ini_path.exists():
self.logger.warning(f"enblocal.ini not found at: {enblocal_ini_path}")
return False
# Read existing INI with same settings as modlist_handler.py
config = configparser.ConfigParser(
allow_no_value=True,
delimiters=['=']
)
config.optionxform = str # Preserve case sensitivity
# Read with encoding handling (same pattern as modlist_handler.py)
try:
with open(enblocal_ini_path, 'r', encoding='utf-8-sig') as f:
config.read_file(f)
except UnicodeDecodeError:
with open(enblocal_ini_path, 'r', encoding='latin-1') as f:
config.read_file(f)
except configparser.DuplicateSectionError as e:
# If file has duplicate [GLOBAL] sections, log warning and skip
self.logger.warning(f"enblocal.ini has duplicate sections: {e}. Skipping modification.")
return False
# Check if [GLOBAL] section exists (case-insensitive check)
global_section_exists = False
global_section_name = None
# Find existing [GLOBAL] section (case-insensitive)
for section_name in config.sections():
if section_name.upper() == 'GLOBAL':
global_section_exists = True
global_section_name = section_name # Use actual case
break
# Check current LinuxVersion value
needs_update = False
if global_section_exists:
# Section exists - check if LinuxVersion needs updating
current_value = config.get(global_section_name, 'LinuxVersion', fallback=None)
if current_value is None or current_value.lower() != 'true':
needs_update = True
else:
# Section doesn't exist - we need to add it
needs_update = True
# If no changes needed, return success
if not needs_update:
self.logger.debug(f"enblocal.ini already has LinuxVersion=true in [GLOBAL] section")
return True
# Changes needed - create backup first
backup_path = enblocal_ini_path.with_suffix('.ini.jackify_backup')
try:
if not backup_path.exists():
shutil.copy2(enblocal_ini_path, backup_path)
self.logger.debug(f"Created backup: {backup_path}")
except Exception as e:
self.logger.warning(f"Failed to create backup: {e}. Proceeding anyway.")
# Make changes
if not global_section_exists:
# Add [GLOBAL] section (configparser will use exact case 'GLOBAL')
config.add_section('GLOBAL')
global_section_name = 'GLOBAL'
self.logger.debug("Added [GLOBAL] section to enblocal.ini")
# Set LinuxVersion=true
config.set(global_section_name, 'LinuxVersion', 'true')
self.logger.debug(f"Set LinuxVersion=true in [GLOBAL] section")
# Write back to file
with open(enblocal_ini_path, 'w', encoding='utf-8') as f:
config.write(f, space_around_delimiters=False)
self.logger.info(f"Successfully configured enblocal.ini: {enblocal_ini_path}")
return True
except configparser.DuplicateSectionError as e:
# Handle duplicate sections gracefully
self.logger.error(f"enblocal.ini has duplicate [GLOBAL] sections: {e}")
return False
except configparser.Error as e:
# Handle other configparser errors
self.logger.error(f"ConfigParser error reading enblocal.ini: {e}")
return False
except Exception as e:
# Handle any other errors
self.logger.error(f"Unexpected error configuring enblocal.ini: {e}", exc_info=True)
return False
def configure_enb_for_linux(self, modlist_path: Path) -> Tuple[bool, Optional[str], bool]:
"""
Main entry point: detect ENB and configure enblocal.ini.
Safe for modlists without ENB - returns success with no message.
Args:
modlist_path: Path to modlist installation directory
Returns:
Tuple[bool, Optional[str], bool]: (success, message, enb_detected)
- success: True if successful or no ENB detected, False on error
- message: Human-readable message (None if no action taken)
- enb_detected: True if ENB was detected, False otherwise
"""
try:
# Step 1: Detect ENB (safe - just searches for files)
enb_info = self.detect_enb_in_modlist(modlist_path)
enb_detected = enb_info.get('has_enb', False)
# Step 2: If no ENB detected, return success (no action needed)
if not enb_detected:
return (True, None, False) # Safe: no ENB, nothing to do
# Step 3: Find enblocal.ini
enblocal_path = enb_info.get('enblocal_ini')
if not enblocal_path:
# ENB detected but no enblocal.ini found - this is unusual but not an error
self.logger.warning("ENB detected but enblocal.ini not found - may be configured elsewhere")
return (True, None, True) # ENB detected but no config file
# Step 4: Configure enblocal.ini (safe method with all checks)
enblocal_path_obj = Path(enblocal_path)
success = self.ensure_linux_version_setting(enblocal_path_obj)
if success:
return (True, "ENB configured for Linux compatibility", True)
else:
# Non-blocking: log error but don't fail workflow
return (False, "Failed to configure ENB (see logs for details)", True)
except Exception as e:
# Catch-all error handling - never break the workflow
self.logger.error(f"Error in ENB configuration: {e}", exc_info=True)
return (False, "ENB configuration error (see logs)", False)

View File

@@ -604,6 +604,11 @@ class FileSystemHandler:
"""
Create required directories for a game modlist
This includes both Linux home directories and Wine prefix directories.
Creating the Wine prefix Documents directories is critical for USVFS
to work properly on first launch - USVFS needs the target directory
to exist before it can virtualize profile INI files.
Args:
game_name: Name of the game (e.g., skyrimse, fallout4)
appid: Steam AppID of the modlist
@@ -614,13 +619,24 @@ class FileSystemHandler:
try:
# Define base paths
home_dir = os.path.expanduser("~")
# Game-specific Documents directory names (for both Linux home and Wine prefix)
game_docs_dirs = {
"skyrimse": "Skyrim Special Edition",
"fallout4": "Fallout4",
"falloutnv": "FalloutNV",
"oblivion": "Oblivion",
"enderal": "Enderal Special Edition",
"enderalse": "Enderal Special Edition"
}
game_dirs = {
# Common directories needed across all games
"common": [
os.path.join(home_dir, ".local", "share", "Steam", "steamapps", "compatdata", appid, "pfx"),
os.path.join(home_dir, ".steam", "steam", "steamapps", "compatdata", appid, "pfx")
],
# Game-specific directories
# Game-specific directories in Linux home (legacy, may not be needed)
"skyrimse": [
os.path.join(home_dir, "Documents", "My Games", "Skyrim Special Edition"),
],
@@ -635,18 +651,52 @@ class FileSystemHandler:
]
}
# Create common directories
# Create common directories (compatdata pfx paths)
for dir_path in game_dirs["common"]:
if dir_path and os.path.exists(os.path.dirname(dir_path)):
os.makedirs(dir_path, exist_ok=True)
self.logger.debug(f"Created directory: {dir_path}")
# Create game-specific directories
# Create game-specific directories in Linux home (legacy support)
if game_name in game_dirs:
for dir_path in game_dirs[game_name]:
os.makedirs(dir_path, exist_ok=True)
self.logger.debug(f"Created game-specific directory: {dir_path}")
# CRITICAL: Create game-specific Documents directories in Wine prefix
# This is required for USVFS to virtualize profile INI files on first launch
if game_name in game_docs_dirs:
docs_dir_name = game_docs_dirs[game_name]
# Find compatdata path for this AppID
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
compatdata_path = path_handler.find_compat_data(appid)
if compatdata_path:
# Create Documents/My Games/{GameName} in Wine prefix
wine_docs_path = os.path.join(
str(compatdata_path),
"pfx",
"drive_c",
"users",
"steamuser",
"Documents",
"My Games",
docs_dir_name
)
try:
os.makedirs(wine_docs_path, exist_ok=True)
self.logger.info(f"Created Wine prefix Documents directory for USVFS: {wine_docs_path}")
self.logger.debug(f"This allows USVFS to virtualize profile INI files on first launch")
except Exception as e:
self.logger.warning(f"Could not create Wine prefix Documents directory {wine_docs_path}: {e}")
# Don't fail completely - this is a first-launch optimization
else:
self.logger.warning(f"Could not find compatdata path for AppID {appid}, skipping Wine prefix Documents directory creation")
self.logger.debug("Wine prefix Documents directories will be created when game runs for first time")
return True
except Exception as e:
self.logger.error(f"Error creating required directories: {e}")

View File

@@ -644,6 +644,29 @@ class ModlistMenuHandler:
if status_line:
print()
# Configure ENB for Linux compatibility (non-blocking, same as GUI)
enb_detected = False
try:
from ..handlers.enb_handler import ENBHandler
from pathlib import Path
enb_handler = ENBHandler()
install_dir = Path(context.get('path', ''))
if install_dir.exists():
enb_success, enb_message, enb_detected = enb_handler.configure_enb_for_linux(install_dir)
if enb_message:
if enb_success:
self.logger.info(enb_message)
update_status(enb_message)
else:
self.logger.warning(enb_message)
# Non-blocking: continue workflow even if ENB config fails
except Exception as e:
self.logger.warning(f"ENB configuration skipped due to error: {e}")
# Continue workflow - ENB config is optional
print("")
print("")
print("") # Extra blank line before completion
@@ -655,9 +678,24 @@ class ModlistMenuHandler:
print(f"• You should now be able to Launch '{context.get('name')}' through Steam")
print("• Congratulations and enjoy the game!")
print("")
print("NOTE: If you experience ENB issues, consider using GE-Proton 10-14 instead of")
print(" Valve's Proton 10 (known ENB compatibility issues in Valve's Proton 10).")
print("")
# Show ENB-specific warning if ENB was detected (replaces generic note)
if enb_detected:
print(f"{COLOR_WARNING}⚠️ ENB DETECTED{COLOR_RESET}")
print("")
print("If you plan on using ENB as part of this modlist, you will need to use")
print("one of the following Proton versions, otherwise you will have issues:")
print("")
print(" (in order of recommendation)")
print(f" {COLOR_SUCCESS}• Proton-CachyOS{COLOR_RESET}")
print(f" {COLOR_INFO}• GE-Proton 10-14 or lower{COLOR_RESET}")
print(f" {COLOR_WARNING}• Proton 9 from Valve{COLOR_RESET}")
print("")
print(f"{COLOR_WARNING}Note: Valve's Proton 10 has known ENB compatibility issues.{COLOR_RESET}")
print("")
else:
# No ENB detected - no warning needed
pass
from jackify.shared.paths import get_jackify_logs_dir
print(f"Detailed log available at: {get_jackify_logs_dir()}/Configure_New_Modlist_workflow.log")
# Only wait for input in CLI mode, not GUI mode

View File

@@ -775,6 +775,37 @@ class ModlistHandler:
self.logger.warning(f"Error enabling dotfiles visibility: {e} (non-critical, continuing)")
self.logger.info("Step 4.6: Enabling dotfiles visibility... Done")
# Step 4.7: Create Wine prefix Documents directories for USVFS
# This is critical for USVFS to virtualize profile INI files on first launch
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Creating Wine prefix Documents directories for USVFS")
self.logger.info("Step 4.7: Creating Wine prefix Documents directories for USVFS...")
try:
if self.appid and self.game_var:
# Map game_var to game_name for create_required_dirs
game_name_map = {
"skyrimspecialedition": "skyrimse",
"fallout4": "fallout4",
"falloutnv": "falloutnv",
"oblivion": "oblivion",
"enderalspecialedition": "enderalse"
}
game_name = game_name_map.get(self.game_var.lower(), None)
if game_name:
appid_str = str(self.appid)
if self.filesystem_handler.create_required_dirs(game_name, appid_str):
self.logger.info("Wine prefix Documents directories created successfully for USVFS")
else:
self.logger.warning("Failed to create Wine prefix Documents directories (non-critical, continuing)")
else:
self.logger.debug(f"Game {self.game_var} not in directory creation map, skipping")
else:
self.logger.warning("AppID or game_var not available, skipping Wine prefix Documents directory creation")
except Exception as e:
self.logger.warning(f"Error creating Wine prefix Documents directories: {e} (non-critical, continuing)")
self.logger.info("Step 4.7: Creating Wine prefix Documents directories... Done")
# Step 5: Ensure permissions of Modlist directory
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Setting ownership and permissions for modlist directory")
@@ -1685,50 +1716,35 @@ class ModlistHandler:
return False
def _find_wine_binary_for_registry(self) -> Optional[str]:
"""Find the appropriate Wine binary for registry operations using user's configured Proton"""
"""Find wine binary from Install Proton path"""
try:
# Use the user's configured Proton version from settings
# Use Install Proton from config (used by jackify-engine)
from ..handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
user_proton_path = config_handler.get_game_proton_path()
proton_path = config_handler.get_proton_path()
if user_proton_path and user_proton_path != 'auto':
# User has selected a specific Proton version
proton_path = Path(user_proton_path).expanduser()
if proton_path:
proton_path = Path(proton_path).expanduser()
# Check for wine binary in both GE-Proton and Valve Proton structures
# Check both GE-Proton and Valve Proton structures
wine_candidates = [
proton_path / "files" / "bin" / "wine", # GE-Proton structure
proton_path / "dist" / "bin" / "wine" # Valve Proton structure
proton_path / "files" / "bin" / "wine", # GE-Proton
proton_path / "dist" / "bin" / "wine" # Valve Proton
]
for wine_path in wine_candidates:
if wine_path.exists() and wine_path.is_file():
self.logger.info(f"Using Wine binary from user's configured Proton: {wine_path}")
return str(wine_path)
for wine_bin in wine_candidates:
if wine_bin.exists() and wine_bin.is_file():
return str(wine_bin)
# Wine binary not found at expected paths - search recursively in Proton directory
self.logger.debug(f"Wine binary not found at expected paths in {proton_path}, searching recursively...")
wine_binary = self._search_wine_in_proton_directory(proton_path)
if wine_binary:
self.logger.info(f"Found Wine binary via recursive search in Proton directory: {wine_binary}")
return wine_binary
self.logger.warning(f"User's configured Proton path has no wine binary: {user_proton_path}")
# Fallback: Try to use same Steam library detection as main Proton detection
# Fallback: use best detected Proton
from ..handlers.wine_utils import WineUtils
best_proton = WineUtils.select_best_proton()
if best_proton:
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
if wine_binary:
self.logger.info(f"Using Wine binary from detected Proton: {wine_binary}")
return wine_binary
# NEVER fall back to system wine - it will break Proton prefixes with architecture mismatches
self.logger.error("No suitable Proton Wine binary found for registry operations")
return None
except Exception as e:
self.logger.error(f"Error finding Wine binary: {e}")
return None

View File

@@ -48,10 +48,11 @@ logger = logging.getLogger(__name__) # Standard logger init
# Helper function to get path to jackify-install-engine
def get_jackify_engine_path():
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
# Running inside the bundled AppImage (frozen)
# Engine is expected at <bundle_root>/jackify/engine/jackify-engine
return os.path.join(sys._MEIPASS, 'jackify', 'engine', 'jackify-engine')
appdir = os.environ.get('APPDIR')
if appdir:
# Running inside AppImage
# Engine is expected at <appdir>/opt/jackify/engine/jackify-engine
return os.path.join(appdir, 'opt', 'jackify', 'engine', 'jackify-engine')
else:
# Running in a normal Python environment from source
# Current file is in src/jackify/backend/handlers/modlist_install_cli.py
@@ -617,8 +618,17 @@ class ModlistInstallCLI:
modlist_arg = self.context.get('modlist_value') or self.context.get('machineid')
machineid = self.context.get('machineid')
api_key = self.context['nexus_api_key']
oauth_info = self.context.get('nexus_oauth_info')
# CRITICAL: Re-check authentication right before launching engine
# This ensures we use current auth state, not stale cached values from context
# (e.g., if user revoked OAuth after context was created)
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
current_api_key, current_oauth_info = auth_service.get_auth_for_engine()
# Use current auth state, fallback to context values only if current check failed
api_key = current_api_key or self.context.get('nexus_api_key')
oauth_info = current_oauth_info or self.context.get('nexus_oauth_info')
# Path to the engine binary
engine_path = get_jackify_engine_path()
@@ -687,7 +697,11 @@ class ModlistInstallCLI:
# Prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY (legacy)
if oauth_info:
os.environ['NEXUS_OAUTH_INFO'] = oauth_info
self.logger.debug(f"Set NEXUS_OAUTH_INFO for engine (supports auto-refresh)")
# CRITICAL: Set client_id so engine can refresh tokens with correct client_id
# Engine's RefreshToken method reads this to use our "jackify" client_id instead of hardcoded "wabbajack"
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
self.logger.debug(f"Set NEXUS_OAUTH_INFO and NEXUS_OAUTH_CLIENT_ID={NexusOAuthService.CLIENT_ID} for engine (supports auto-refresh)")
# Also set NEXUS_API_KEY for backward compatibility
if api_key:
os.environ['NEXUS_API_KEY'] = api_key
@@ -701,6 +715,8 @@ class ModlistInstallCLI:
del os.environ['NEXUS_API_KEY']
if 'NEXUS_OAUTH_INFO' in os.environ:
del os.environ['NEXUS_OAUTH_INFO']
if 'NEXUS_OAUTH_CLIENT_ID' in os.environ:
del os.environ['NEXUS_OAUTH_CLIENT_ID']
self.logger.debug(f"No Nexus auth available, cleared inherited env vars")
os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1"
@@ -780,6 +796,16 @@ class ModlistInstallCLI:
if chunk == b'\n':
# Complete line - decode and print
line = buffer.decode('utf-8', errors='replace')
# Filter FILE_PROGRESS spam but keep the status line before it
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
# Skip this line entirely if it's only FILE_PROGRESS
buffer = b''
last_progress_time = time.time()
continue
# Enhance Nexus download errors with modlist context
enhanced_line = self._enhance_nexus_error(line)
print(enhanced_line, end='')
@@ -788,6 +814,16 @@ class ModlistInstallCLI:
elif chunk == b'\r':
# Carriage return - decode and print without newline
line = buffer.decode('utf-8', errors='replace')
# Filter FILE_PROGRESS spam but keep the status line before it
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
# Skip this line entirely if it's only FILE_PROGRESS
buffer = b''
last_progress_time = time.time()
continue
# Enhance Nexus download errors with modlist context
enhanced_line = self._enhance_nexus_error(line)
print(enhanced_line, end='')

View File

@@ -844,6 +844,7 @@ class ProgressStateManager:
self._file_history = {}
self._wabbajack_entry_name = None
self._synthetic_flag = "_synthetic_wabbajack"
self._previous_phase = None # Track phase changes to reset stale data
def process_line(self, line: str) -> bool:
"""
@@ -862,13 +863,56 @@ class ProgressStateManager:
updated = False
# Update phase
if parsed.phase:
# Update phase - detect phase changes to reset stale data
phase_changed = False
if parsed.phase and parsed.phase != self.state.phase:
# Phase is changing - selectively reset stale data from previous phase
previous_phase = self.state.phase
# Only reset data sizes when transitioning FROM VALIDATE phase
# Validation phase data sizes are from .wabbajack file and shouldn't persist
if previous_phase == InstallationPhase.VALIDATE and not parsed.data_info:
# Clear old validation data sizes (e.g., 339.0MB/339.1MB from .wabbajack)
if self.state.data_total > 0:
self.state.data_processed = 0
self.state.data_total = 0
updated = True
# Clear "Validating" phase name immediately when transitioning away from VALIDATE
# This ensures stale phase name doesn't persist into download phase
if previous_phase == InstallationPhase.VALIDATE:
# Transitioning away from VALIDATE - always clear old phase_name
# The new phase will either provide a new phase_name or get_phase_label() will derive it
if self.state.phase_name and 'validat' in self.state.phase_name.lower():
self.state.phase_name = ""
updated = True
phase_changed = True
self._previous_phase = self.state.phase
self.state.phase = parsed.phase
updated = True
elif parsed.phase:
self.state.phase = parsed.phase
updated = True
# Update phase name - clear old phase name if phase changed but no new phase_name provided
if parsed.phase_name:
self.state.phase_name = parsed.phase_name
updated = True
elif phase_changed:
# Phase changed but no new phase_name - clear old phase_name to prevent stale display
# This ensures "Validating" doesn't stick when we transition to DOWNLOAD
if self.state.phase_name and self.state.phase != InstallationPhase.VALIDATE:
# Only clear if we're not in VALIDATE phase anymore
self.state.phase_name = ""
updated = True
# CRITICAL: Always clear "Validating" phase_name if we're in DOWNLOAD phase
# This catches cases where phase didn't change but we're downloading, or phase_name got set again
if self.state.phase == InstallationPhase.DOWNLOAD:
if self.state.phase_name and 'validat' in self.state.phase_name.lower():
self.state.phase_name = ""
updated = True
# Update overall progress
if parsed.overall_percent is not None:

View File

@@ -28,7 +28,8 @@ class ProtontricksHandler:
def __init__(self, steamdeck: bool, logger=None):
self.logger = logger or logging.getLogger(__name__)
self.which_protontricks = None # 'flatpak' or 'native'
self.which_protontricks = None # 'flatpak', 'native', or 'bundled'
self.flatpak_install_type = None # 'user' or 'system' (for flatpak installations)
self.protontricks_version = None
self.protontricks_path = None
self.steamdeck = steamdeck # Store steamdeck status
@@ -209,19 +210,36 @@ class ProtontricksHandler:
except Exception as e:
logger.error(f"Error reading protontricks executable: {e}")
# Check if flatpak protontricks is installed
# Check if flatpak protontricks is installed (check both user and system)
try:
env = self._get_clean_subprocess_env()
result = subprocess.run(
["flatpak", "list"],
# Check user installation first
result_user = subprocess.run(
["flatpak", "list", "--user"],
capture_output=True,
text=True,
env=env
)
if result.returncode == 0 and "com.github.Matoking.protontricks" in result.stdout:
logger.info("Flatpak Protontricks is installed")
if result_user.returncode == 0 and "com.github.Matoking.protontricks" in result_user.stdout:
logger.info("Flatpak Protontricks is installed (user-level)")
self.which_protontricks = 'flatpak'
self.flatpak_install_type = 'user'
return True
# Check system installation
result_system = subprocess.run(
["flatpak", "list", "--system"],
capture_output=True,
text=True,
env=env
)
if result_system.returncode == 0 and "com.github.Matoking.protontricks" in result_system.stdout:
logger.info("Flatpak Protontricks is installed (system-level)")
self.which_protontricks = 'flatpak'
self.flatpak_install_type = 'system'
return True
except FileNotFoundError:
logger.warning("'flatpak' command not found. Cannot check for Flatpak Protontricks.")
except Exception as e:
@@ -230,7 +248,46 @@ class ProtontricksHandler:
# Not found
logger.warning("Protontricks not found (native or flatpak).")
return False
def _get_flatpak_run_args(self) -> List[str]:
"""
Get the correct flatpak run arguments based on installation type.
Returns list starting with ['flatpak', 'run', '--user'|'--system', ...]
"""
base_args = ["flatpak", "run"]
if self.flatpak_install_type == 'user':
base_args.append("--user")
elif self.flatpak_install_type == 'system':
base_args.append("--system")
# If flatpak_install_type is None, don't add flag (shouldn't happen in normal flow)
return base_args
def _get_flatpak_alias_string(self, command=None) -> str:
"""
Get the correct flatpak alias string based on installation type.
Args:
command: Optional command override (e.g., 'protontricks-launch').
If None, returns base protontricks alias.
Returns:
String like 'flatpak run --user com.github.Matoking.protontricks'
"""
flag = f"--{self.flatpak_install_type}" if self.flatpak_install_type else ""
if command:
# For commands like protontricks-launch
if flag:
return f"flatpak run {flag} --command={command} com.github.Matoking.protontricks"
else:
return f"flatpak run --command={command} com.github.Matoking.protontricks"
else:
# Base protontricks command
if flag:
return f"flatpak run {flag} com.github.Matoking.protontricks"
else:
return f"flatpak run com.github.Matoking.protontricks"
def check_protontricks_version(self):
"""
Check if the protontricks version is sufficient
@@ -238,7 +295,7 @@ class ProtontricksHandler:
"""
try:
if self.which_protontricks == 'flatpak':
cmd = ["flatpak", "run", "com.github.Matoking.protontricks", "-V"]
cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks", "-V"]
else:
cmd = ["protontricks", "-V"]
@@ -296,7 +353,7 @@ class ProtontricksHandler:
cmd = [python_exe, "-m", "protontricks.cli.main"]
cmd.extend([str(a) for a in args])
elif self.which_protontricks == 'flatpak':
cmd = ["flatpak", "run", "com.github.Matoking.protontricks"]
cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks"]
cmd.extend(args)
else: # native
cmd = ["protontricks"]
@@ -443,15 +500,17 @@ class ProtontricksHandler:
protontricks_alias_exists = "alias protontricks=" in content
launch_alias_exists = "alias protontricks-launch" in content
# Add missing aliases
# Add missing aliases with correct flag based on installation type
with open(bashrc_path, 'a') as f:
if not protontricks_alias_exists:
logger.info("Adding protontricks alias to ~/.bashrc")
f.write("\nalias protontricks='flatpak run com.github.Matoking.protontricks'\n")
alias_cmd = self._get_flatpak_alias_string()
f.write(f"\nalias protontricks='{alias_cmd}'\n")
if not launch_alias_exists:
logger.info("Adding protontricks-launch alias to ~/.bashrc")
f.write("\nalias protontricks-launch='flatpak run --command=protontricks-launch com.github.Matoking.protontricks'\n")
launch_alias_cmd = self._get_flatpak_alias_string(command='protontricks-launch')
f.write(f"\nalias protontricks-launch='{launch_alias_cmd}'\n")
return True
else:
@@ -500,7 +559,7 @@ class ProtontricksHandler:
try:
cmd = [] # Initialize cmd list
if self.which_protontricks == 'flatpak':
cmd = ["flatpak", "run", "com.github.Matoking.protontricks", "-l"]
cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks", "-l"]
elif self.protontricks_path:
cmd = [self.protontricks_path, "-l"]
else:
@@ -672,9 +731,9 @@ class ProtontricksHandler:
# Bundled-runtime fix: Use cleaned environment
env = self._get_clean_subprocess_env()
env["WINEDEBUG"] = "-all"
if self.which_protontricks == 'flatpak':
cmd = ["flatpak", "run", "com.github.Matoking.protontricks", "--no-bwrap", appid, "win10"]
cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks", "--no-bwrap", appid, "win10"]
else:
cmd = ["protontricks", "--no-bwrap", appid, "win10"]
@@ -700,19 +759,21 @@ class ProtontricksHandler:
if os.path.exists(bashrc_path):
with open(bashrc_path, 'r') as f:
content = f.read()
protontricks_alias_exists = "alias protontricks='flatpak run com.github.Matoking.protontricks'" in content
launch_alias_exists = "alias protontricks-launch='flatpak run --command=protontricks-launch com.github.Matoking.protontricks'" in content
# Add aliases if they don't exist
protontricks_alias_exists = "alias protontricks=" in content
launch_alias_exists = "alias protontricks-launch=" in content
# Add aliases if they don't exist with correct flag based on installation type
with open(bashrc_path, 'a') as f:
if not protontricks_alias_exists:
f.write("\n# Jackify: Protontricks alias\n")
f.write("alias protontricks='flatpak run com.github.Matoking.protontricks'\n")
alias_cmd = self._get_flatpak_alias_string()
f.write(f"alias protontricks='{alias_cmd}'\n")
logger.debug("Added protontricks alias to ~/.bashrc")
if not launch_alias_exists:
f.write("\n# Jackify: Protontricks-launch alias\n")
f.write("alias protontricks-launch='flatpak run --command=protontricks-launch com.github.Matoking.protontricks'\n")
launch_alias_cmd = self._get_flatpak_alias_string(command='protontricks-launch')
f.write(f"alias protontricks-launch='{launch_alias_cmd}'\n")
logger.debug("Added protontricks-launch alias to ~/.bashrc")
logger.info("Protontricks aliases created successfully")
@@ -769,7 +830,7 @@ class ProtontricksHandler:
# Use bundled Python module
cmd = [python_exe, "-m", "protontricks.cli.launch", "--appid", appid, str(installer_path)]
elif self.which_protontricks == 'flatpak':
cmd = ["flatpak", "run", "--command=protontricks-launch", "com.github.Matoking.protontricks", "--appid", appid, str(installer_path)]
cmd = self._get_flatpak_run_args() + ["--command=protontricks-launch", "com.github.Matoking.protontricks", "--appid", appid, str(installer_path)]
else: # native
launch_path = shutil.which("protontricks-launch")
if not launch_path:

View File

@@ -5,6 +5,7 @@ import time
import resource
import sys
import shutil
import logging
def get_safe_python_executable():
"""
@@ -19,7 +20,6 @@ def get_safe_python_executable():
is_appimage = (
'APPIMAGE' in os.environ or
'APPDIR' in os.environ or
(hasattr(sys, 'frozen') and sys.frozen) or
(sys.argv[0] and sys.argv[0].endswith('.AppImage'))
)
@@ -73,33 +73,24 @@ def get_clean_subprocess_env(extra_env=None):
path_parts.append(sys_path)
# Add bundled tools directory to PATH if running as AppImage
# This ensures lz4, cabextract, and winetricks are available to subprocesses
# This ensures cabextract and winetricks are available to subprocesses
# System utilities (wget, curl, unzip, xz, gzip, sha256sum) come from system PATH
# Note: appdir was saved before env cleanup above
# Note: lz4 was only needed for TTW installer and is no longer bundled
tools_dir = None
if appdir:
# Running as AppImage - use APPDIR
tools_dir = os.path.join(appdir, 'opt', 'jackify', 'tools')
# Verify the tools directory exists and contains lz4
logger = logging.getLogger(__name__)
if not os.path.isdir(tools_dir):
logger.debug(f"Tools directory not found: {tools_dir}")
tools_dir = None
elif not os.path.exists(os.path.join(tools_dir, 'lz4')):
# Tools dir exists but lz4 not there - might be a different layout
tools_dir = None
elif getattr(sys, 'frozen', False):
# PyInstaller frozen - try to find tools relative to executable
exe_path = Path(sys.executable)
# In PyInstaller, sys.executable is the bundled executable
# Tools should be in the same directory or a tools subdirectory
possible_tools_dirs = [
exe_path.parent / 'tools',
exe_path.parent / 'opt' / 'jackify' / 'tools',
]
for possible_dir in possible_tools_dirs:
if possible_dir.is_dir() and (possible_dir / 'lz4').exists():
tools_dir = str(possible_dir)
break
else:
# Tools directory exists - add it to PATH for cabextract, winetricks, etc.
logger.debug(f"Found bundled tools directory at: {tools_dir}")
else:
logging.getLogger(__name__).debug("APPDIR not set - not running as AppImage, skipping bundled tools")
# Build final PATH: system PATH first, then bundled tools (lz4, cabextract, winetricks)
# System utilities (wget, curl, unzip, xz, gzip, sha256sum) are preferred from system
@@ -112,7 +103,7 @@ def get_clean_subprocess_env(extra_env=None):
final_path_parts.append(path_part)
seen.add(path_part)
# Then add bundled tools directory (for lz4, cabextract, winetricks)
# Then add bundled tools directory (for cabextract, winetricks, etc.)
if tools_dir and os.path.isdir(tools_dir) and tools_dir not in seen:
final_path_parts.append(tools_dir)
seen.add(tools_dir)

View File

@@ -293,9 +293,13 @@ class TTWInstallerHandler:
try:
env = get_clean_subprocess_env()
# CRITICAL: cwd must be the directory containing the executable, not the extraction root
# This is because AppContext.BaseDirectory (used by TTW installer to find BundledBinaries)
# is the directory containing the executable, not the working directory
exe_dir = str(self.ttw_installer_executable_path.parent)
process = subprocess.Popen(
cmd,
cwd=str(self.ttw_installer_dir),
cwd=exe_dir,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
@@ -401,37 +405,20 @@ class TTWInstallerHandler:
try:
env = get_clean_subprocess_env()
# Ensure lz4 is in PATH (critical for TTW_Linux_Installer)
import shutil
appdir = env.get('APPDIR')
if appdir:
tools_dir = os.path.join(appdir, 'opt', 'jackify', 'tools')
bundled_lz4 = os.path.join(tools_dir, 'lz4')
if os.path.exists(bundled_lz4) and os.access(bundled_lz4, os.X_OK):
current_path = env.get('PATH', '')
path_parts = [p for p in current_path.split(':') if p and p != tools_dir]
env['PATH'] = f"{tools_dir}:{':'.join(path_parts)}"
self.logger.info(f"Added bundled lz4 to PATH: {tools_dir}")
# Verify lz4 is available
lz4_path = shutil.which('lz4', path=env.get('PATH', ''))
if not lz4_path:
system_lz4 = shutil.which('lz4')
if system_lz4:
lz4_dir = os.path.dirname(system_lz4)
env['PATH'] = f"{lz4_dir}:{env.get('PATH', '')}"
self.logger.info(f"Added system lz4 to PATH: {lz4_dir}")
else:
return None, "lz4 is required but not found in PATH"
# Note: TTW_Linux_Installer bundles its own lz4 and will find it via AppContext.BaseDirectory
# We set cwd to the executable's directory so AppContext.BaseDirectory matches the working directory
# Open output file for writing
output_fh = open(output_file, 'w', encoding='utf-8', buffering=1)
# Start process with output redirected to file
# CRITICAL: cwd must be the directory containing the executable, not the extraction root
# This is because AppContext.BaseDirectory (used by TTW installer to find BundledBinaries)
# is the directory containing the executable, not the working directory
exe_dir = str(self.ttw_installer_executable_path.parent)
process = subprocess.Popen(
cmd,
cwd=str(self.ttw_installer_dir),
cwd=exe_dir,
env=env,
stdout=output_fh,
stderr=subprocess.STDOUT,
@@ -552,9 +539,13 @@ class TTWInstallerHandler:
try:
env = get_clean_subprocess_env()
# CRITICAL: cwd must be the directory containing the executable, not the extraction root
# This is because AppContext.BaseDirectory (used by TTW installer to find BundledBinaries)
# is the directory containing the executable, not the working directory
exe_dir = str(self.ttw_installer_executable_path.parent)
process = subprocess.Popen(
cmd,
cwd=str(self.ttw_installer_dir),
cwd=exe_dir,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,

View File

@@ -0,0 +1,601 @@
"""
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)

View File

@@ -48,41 +48,56 @@ class WinetricksHandler:
self.logger.error(f"Bundled winetricks not found. Tried paths: {possible_paths}")
return None
def _get_bundled_cabextract(self) -> Optional[str]:
def _get_bundled_tool(self, tool_name: str, fallback_to_system: bool = True) -> Optional[str]:
"""
Get the path to the bundled cabextract binary, checking same locations as winetricks
Get the path to a bundled tool binary, checking same locations as winetricks.
Args:
tool_name: Name of the tool (e.g., 'cabextract', 'wget', 'unzip')
fallback_to_system: If True, fall back to system PATH if bundled version not found
Returns:
Path to the tool, or None if not found
"""
possible_paths = []
# AppImage environment - same pattern as winetricks detection
if os.environ.get('APPDIR'):
appdir_path = os.path.join(os.environ['APPDIR'], 'opt', 'jackify', 'tools', 'cabextract')
appdir_path = os.path.join(os.environ['APPDIR'], 'opt', 'jackify', 'tools', tool_name)
possible_paths.append(appdir_path)
# Development environment - relative to module location, same as winetricks
module_dir = Path(__file__).parent.parent.parent # Go from handlers/ up to jackify/
dev_path = module_dir / 'tools' / 'cabextract'
dev_path = module_dir / 'tools' / tool_name
possible_paths.append(str(dev_path))
# Try each path until we find one that works
for path in possible_paths:
if os.path.exists(path) and os.access(path, os.X_OK):
self.logger.debug(f"Found bundled cabextract at: {path}")
self.logger.debug(f"Found bundled {tool_name} at: {path}")
return str(path)
# Fallback to system PATH
try:
import shutil
system_cabextract = shutil.which('cabextract')
if system_cabextract:
self.logger.debug(f"Using system cabextract: {system_cabextract}")
return system_cabextract
except Exception:
pass
# Fallback to system PATH if requested
if fallback_to_system:
try:
import shutil
system_tool = shutil.which(tool_name)
if system_tool:
self.logger.debug(f"Using system {tool_name}: {system_tool}")
return system_tool
except Exception:
pass
self.logger.warning("Bundled cabextract not found in tools directory")
self.logger.debug(f"Bundled {tool_name} not found in tools directory")
return None
def _get_bundled_cabextract(self) -> Optional[str]:
"""
Get the path to the bundled cabextract binary.
Maintains backward compatibility with existing code.
"""
return self._get_bundled_tool('cabextract', fallback_to_system=True)
def is_available(self) -> bool:
"""
Check if winetricks is available and ready to use
@@ -251,13 +266,81 @@ class WinetricksHandler:
self.logger.error(f"Cannot run winetricks: Failed to get Proton wine binary: {e}")
return False
# Set up bundled cabextract for winetricks
bundled_cabextract = self._get_bundled_cabextract()
if bundled_cabextract:
env['PATH'] = f"{os.path.dirname(bundled_cabextract)}:{env.get('PATH', '')}"
self.logger.info(f"Using bundled cabextract: {bundled_cabextract}")
# Set up bundled tools directory for winetricks
# Get tools directory from any bundled tool (winetricks, cabextract, etc.)
tools_dir = None
bundled_tools = []
# Check for bundled tools and collect their directory
tool_names = ['cabextract', 'wget', 'unzip', '7z', 'xz', 'sha256sum']
for tool_name in tool_names:
bundled_tool = self._get_bundled_tool(tool_name, fallback_to_system=False)
if bundled_tool:
bundled_tools.append(tool_name)
if tools_dir is None:
tools_dir = os.path.dirname(bundled_tool)
# Prepend tools directory to PATH if we have any bundled tools
if tools_dir:
env['PATH'] = f"{tools_dir}:{env.get('PATH', '')}"
self.logger.info(f"Using bundled tools directory: {tools_dir}")
self.logger.info(f"Bundled tools available: {', '.join(bundled_tools)}")
else:
self.logger.warning("Bundled cabextract not found, relying on system PATH")
self.logger.debug("No bundled tools found, relying on system PATH")
# CRITICAL: Check for winetricks dependencies BEFORE attempting installation
# This helps diagnose failures on systems where dependencies are missing
self.logger.info("=== Checking winetricks dependencies ===")
missing_deps = []
dependency_checks = {
'wget': 'wget',
'curl': 'curl',
'aria2c': 'aria2c',
'unzip': 'unzip',
'7z': ['7z', '7za', '7zr'],
'xz': 'xz',
'sha256sum': ['sha256sum', 'sha256', 'shasum'],
'perl': 'perl'
}
for dep_name, commands in dependency_checks.items():
found = False
if isinstance(commands, str):
commands = [commands]
# First check for bundled version
bundled_tool = None
for cmd in commands:
bundled_tool = self._get_bundled_tool(cmd, fallback_to_system=False)
if bundled_tool:
self.logger.info(f"{dep_name}: {bundled_tool} (bundled)")
found = True
break
# If not bundled, check system PATH
if not found:
for cmd in commands:
try:
result = subprocess.run(['which', cmd], capture_output=True, timeout=2)
if result.returncode == 0:
cmd_path = result.stdout.decode().strip()
self.logger.info(f"{dep_name}: {cmd_path} (system)")
found = True
break
except Exception:
pass
if not found:
missing_deps.append(dep_name)
self.logger.warning(f"{dep_name}: NOT FOUND (neither bundled nor system)")
if missing_deps:
self.logger.warning(f"Missing winetricks dependencies: {', '.join(missing_deps)}")
self.logger.warning("Winetricks may fail if these are required for component installation")
self.logger.warning("Critical dependencies: wget/curl/aria2c (download), unzip/7z (extract)")
else:
self.logger.info("All winetricks dependencies found")
self.logger.info("========================================")
# Set winetricks cache to jackify_data_dir for self-containment
from jackify.shared.paths import get_jackify_data_dir
@@ -389,40 +472,80 @@ class WinetricksHandler:
'attempt': attempt
}
self.logger.error(f"Winetricks command failed (Attempt {attempt}/{max_attempts}). Return Code: {result.returncode}")
self.logger.error(f"Stdout: {result.stdout.strip()}")
self.logger.error(f"Stderr: {result.stderr.strip()}")
# CRITICAL: Always log full error details (not just in debug mode)
# This helps diagnose failures on systems we can't replicate
self.logger.error("=" * 80)
self.logger.error(f"WINETRICKS FAILED (Attempt {attempt}/{max_attempts})")
self.logger.error(f"Return Code: {result.returncode}")
self.logger.error("")
self.logger.error("STDOUT:")
if result.stdout.strip():
for line in result.stdout.strip().split('\n'):
self.logger.error(f" {line}")
else:
self.logger.error(" (empty)")
self.logger.error("")
self.logger.error("STDERR:")
if result.stderr.strip():
for line in result.stderr.strip().split('\n'):
self.logger.error(f" {line}")
else:
self.logger.error(" (empty)")
self.logger.error("=" * 80)
# Enhanced error diagnostics with actionable information
stderr_lower = result.stderr.lower()
stdout_lower = result.stdout.lower()
# Log which diagnostic category matches
diagnostic_found = False
if "command not found" in stderr_lower or "no such file" in stderr_lower:
self.logger.error("DIAGNOSTIC: Winetricks or dependency binary not found")
self.logger.error(" - Bundled winetricks may be missing dependencies")
self.logger.error(" - Check dependency check output above for missing tools")
self.logger.error(" - Will attempt protontricks fallback if all attempts fail")
diagnostic_found = True
elif "permission denied" in stderr_lower:
self.logger.error("DIAGNOSTIC: Permission issue detected")
self.logger.error(f" - Check permissions on: {self.winetricks_path}")
self.logger.error(f" - Check permissions on WINEPREFIX: {env.get('WINEPREFIX', 'N/A')}")
diagnostic_found = True
elif "timeout" in stderr_lower:
self.logger.error("DIAGNOSTIC: Timeout issue detected during component download/install")
self.logger.error(" - Network may be slow or unstable")
self.logger.error(" - Component download may be taking too long")
diagnostic_found = True
elif "sha256sum mismatch" in stderr_lower or "sha256sum" in stdout_lower:
self.logger.error("DIAGNOSTIC: Checksum verification failed")
self.logger.error(" - Component download may be corrupted")
self.logger.error(" - Network issue or upstream file change")
elif "curl" in stderr_lower or "wget" in stderr_lower:
self.logger.error("DIAGNOSTIC: Download tool (curl/wget) issue")
diagnostic_found = True
elif "curl" in stderr_lower or "wget" in stderr_lower or "aria2c" in stderr_lower:
self.logger.error("DIAGNOSTIC: Download tool (curl/wget/aria2c) issue")
self.logger.error(" - Network connectivity problem or missing download tool")
self.logger.error(" - Check dependency check output above")
diagnostic_found = True
elif "cabextract" in stderr_lower:
self.logger.error("DIAGNOSTIC: cabextract missing or failed")
self.logger.error(" - Required for extracting Windows cabinet files")
elif "unzip" in stderr_lower:
self.logger.error("DIAGNOSTIC: unzip missing or failed")
self.logger.error(" - Required for extracting zip archives")
else:
self.logger.error("DIAGNOSTIC: Unknown winetricks failure")
self.logger.error(" - Check full logs for details")
self.logger.error(" - Bundled cabextract should be available, check PATH")
diagnostic_found = True
elif "unzip" in stderr_lower or "7z" in stderr_lower:
self.logger.error("DIAGNOSTIC: Archive extraction tool (unzip/7z) missing or failed")
self.logger.error(" - Required for extracting zip/7z archives")
self.logger.error(" - Check dependency check output above")
diagnostic_found = True
elif "please install" in stderr_lower:
self.logger.error("DIAGNOSTIC: Winetricks explicitly requesting dependency installation")
self.logger.error(" - Winetricks detected missing required tool")
self.logger.error(" - Check dependency check output above")
diagnostic_found = True
if not diagnostic_found:
self.logger.error("DIAGNOSTIC: Unknown winetricks failure pattern")
self.logger.error(" - Error details logged above (STDOUT/STDERR)")
self.logger.error(" - Check dependency check output above for missing tools")
self.logger.error(" - Will attempt protontricks fallback if all attempts fail")
winetricks_failed = True
@@ -438,7 +561,20 @@ class WinetricksHandler:
# All winetricks attempts failed - try automatic fallback to protontricks
if winetricks_failed:
self.logger.error(f"Winetricks failed after {max_attempts} attempts.")
self.logger.error("=" * 80)
self.logger.error(f"WINETRICKS FAILED AFTER {max_attempts} ATTEMPTS")
self.logger.error("")
if last_error_details:
self.logger.error("Last error details:")
if 'returncode' in last_error_details:
self.logger.error(f" Return code: {last_error_details['returncode']}")
if 'stderr' in last_error_details and last_error_details['stderr']:
self.logger.error(f" Last stderr (first 500 chars): {last_error_details['stderr'][:500]}")
if 'stdout' in last_error_details and last_error_details['stdout']:
self.logger.error(f" Last stdout (first 500 chars): {last_error_details['stdout'][:500]}")
self.logger.error("")
self.logger.error("Attempting automatic fallback to protontricks...")
self.logger.error("=" * 80)
# Network diagnostics before fallback (non-fatal)
self.logger.warning("=" * 80)

View File

@@ -23,6 +23,7 @@ class ModlistContext:
mo2_exe_path: Optional[Path] = None
skip_confirmation: bool = False
engine_installed: bool = False # True if installed via jackify-engine
enb_detected: bool = False # True if ENB was detected during configuration
def __post_init__(self):
"""Convert string paths to Path objects."""

View File

@@ -2885,8 +2885,9 @@ echo Prefix creation complete.
logger.info(f"Replacing existing shortcut: {shortcut_name}")
# First, remove the existing shortcut using STL
if getattr(sys, 'frozen', False):
stl_path = Path(sys._MEIPASS) / "steamtinkerlaunch"
appdir = os.environ.get('APPDIR')
if appdir:
stl_path = Path(appdir) / "opt" / "jackify" / "steamtinkerlaunch"
else:
project_root = Path(__file__).parent.parent.parent.parent.parent
stl_path = project_root / "external_repos/steamtinkerlaunch/steamtinkerlaunch"
@@ -3045,7 +3046,25 @@ echo Prefix creation complete.
in_target_section = False
path_updated = False
wine_path = new_path.replace('/', '\\\\')
# Determine Wine drive letter based on SD card detection
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
from jackify.backend.handlers.path_handler import PathHandler
linux_path = Path(new_path)
if FileSystemHandler.is_sd_card(linux_path):
# SD card paths use D: drive
# Strip SD card prefix using the same method as other handlers
relative_sd_path_str = PathHandler._strip_sdcard_path_prefix(linux_path)
wine_path = relative_sd_path_str.replace('/', '\\')
wine_drive = "D:"
logger.debug(f"SD card path detected: {new_path} -> D:\\{wine_path}")
else:
# Regular paths use Z: drive with full path
wine_path = new_path.strip('/').replace('/', '\\')
wine_drive = "Z:"
logger.debug(f"Regular path: {new_path} -> Z:\\{wine_path}")
# Update existing path if found
for i, line in enumerate(lines):
@@ -3055,14 +3074,14 @@ echo Prefix creation complete.
elif stripped_line.startswith('[') and in_target_section:
in_target_section = False
elif in_target_section and f'"{path_key}"' in line:
lines[i] = f'"{path_key}"="Z:\\\\{wine_path}\\\\"\n' # Add trailing backslashes
lines[i] = f'"{path_key}"="{wine_drive}\\\\{wine_path}\\\\"\n' # Add trailing backslashes
path_updated = True
break
# Add new section if path wasn't updated
if not path_updated:
lines.append(f'\n{section_name}\n')
lines.append(f'"{path_key}"="Z:\\\\{wine_path}\\\\"\n') # Add trailing backslashes
lines.append(f'"{path_key}"="{wine_drive}\\\\{wine_path}\\\\"\n') # Add trailing backslashes
# Write updated content
with open(system_reg_path, 'w', encoding='utf-8') as f:

View File

@@ -275,8 +275,16 @@ class ModlistService:
actual_download_path = Path(download_dir_context)
download_dir_str = str(actual_download_path)
api_key = context['nexus_api_key']
oauth_info = context.get('nexus_oauth_info')
# CRITICAL: Re-check authentication right before launching engine
# This ensures we use current auth state, not stale cached values from context
# (e.g., if user revoked OAuth after context was created)
from ..services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
current_api_key, current_oauth_info = auth_service.get_auth_for_engine()
# Use current auth state, fallback to context values only if current check failed
api_key = current_api_key or context.get('nexus_api_key')
oauth_info = current_oauth_info or context.get('nexus_oauth_info')
# Path to the engine binary (copied from working code)
engine_path = get_jackify_engine_path()
@@ -311,6 +319,10 @@ class ModlistService:
# Environment setup - prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY
if oauth_info:
os.environ['NEXUS_OAUTH_INFO'] = oauth_info
# CRITICAL: Set client_id so engine can refresh tokens with correct client_id
# Engine's RefreshToken method reads this to use our "jackify" client_id instead of hardcoded "wabbajack"
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
# Also set NEXUS_API_KEY for backward compatibility
if api_key:
os.environ['NEXUS_API_KEY'] = api_key
@@ -549,18 +561,42 @@ class ModlistService:
success = modlist_menu.run_modlist_configuration_phase(config_context)
debug_callback(f"Configuration phase result: {success}")
# Restore stdout before calling completion callback
# Restore stdout before ENB detection and completion callback
if original_stdout:
sys.stdout = original_stdout
original_stdout = None
# Configure ENB for Linux compatibility (non-blocking)
# Do this BEFORE completion callback so we can pass detection status
enb_detected = False
try:
from ..handlers.enb_handler import ENBHandler
enb_handler = ENBHandler()
enb_success, enb_message, enb_detected = enb_handler.configure_enb_for_linux(context.install_dir)
if enb_message:
if enb_success:
logger.info(enb_message)
if progress_callback:
progress_callback(enb_message)
else:
logger.warning(enb_message)
# Non-blocking: continue workflow even if ENB config fails
except Exception as e:
logger.warning(f"ENB configuration skipped due to error: {e}")
# Continue workflow - ENB config is optional
# Store ENB detection status in context for GUI to use
context.enb_detected = enb_detected
if completion_callback:
if success:
debug_callback("Configuration completed successfully, calling completion callback")
completion_callback(True, "Configuration completed successfully!", context.name)
# Pass ENB detection status through callback
completion_callback(True, "Configuration completed successfully!", context.name, enb_detected)
else:
debug_callback("Configuration failed, calling completion callback with failure")
completion_callback(False, "Configuration failed", context.name)
completion_callback(False, "Configuration failed", context.name, False)
return success

View File

@@ -569,4 +569,57 @@ class NativeSteamService:
except Exception as e:
logger.error(f"Error removing shortcut: {e}")
return False
def create_steam_library_symlinks(self, app_id: int) -> bool:
"""
Create symlink to libraryfolders.vdf in Wine prefix for game detection.
This allows Wabbajack running in the prefix to detect Steam games.
Based on Wabbajack-Proton-AuCu implementation.
Args:
app_id: Steam AppID (unsigned)
Returns:
True if successful
"""
# Ensure Steam user detection is completed first
if not self.steam_path:
if not self.find_steam_user():
logger.error("Cannot create symlinks: Steam user detection failed")
return False
# Find libraryfolders.vdf
libraryfolders_vdf = self.steam_path / "config" / "libraryfolders.vdf"
if not libraryfolders_vdf.exists():
logger.error(f"libraryfolders.vdf not found at: {libraryfolders_vdf}")
return False
# Get compatdata path for this AppID
compat_data = self.steam_path / f"steamapps/compatdata/{app_id}"
if not compat_data.exists():
logger.error(f"Compatdata directory not found: {compat_data}")
return False
# Target directory in Wine prefix
prefix_config_dir = compat_data / "pfx/drive_c/Program Files (x86)/Steam/config"
prefix_config_dir.mkdir(parents=True, exist_ok=True)
# Symlink target
symlink_target = prefix_config_dir / "libraryfolders.vdf"
try:
# Remove existing symlink/file if it exists
if symlink_target.exists() or symlink_target.is_symlink():
symlink_target.unlink()
# Create symlink
symlink_target.symlink_to(libraryfolders_vdf)
logger.info(f"Created symlink: {symlink_target} -> {libraryfolders_vdf}")
return True
except Exception as e:
logger.error(f"Error creating symlink: {e}")
return False

View File

@@ -102,7 +102,6 @@ class NexusOAuthService:
# Determine executable path (DEV mode vs AppImage)
# Check multiple indicators for AppImage execution
is_appimage = (
getattr(sys, 'frozen', False) or # PyInstaller frozen
'APPIMAGE' in env or # AppImage environment variable
'APPDIR' in env or # AppImage directory variable
(sys.argv[0] and sys.argv[0].endswith('.AppImage')) # Executable name
@@ -127,7 +126,8 @@ class NexusOAuthService:
# Running from source (DEV mode)
# Need to ensure we run from the correct directory
src_dir = Path(__file__).parent.parent.parent.parent # Go up to src/
exec_path = f"cd {src_dir} && {sys.executable} -m jackify.frontends.gui"
# Use bash -c with proper quoting for paths with spaces
exec_path = f'bash -c \'cd "{src_dir}" && "{sys.executable}" -m jackify.frontends.gui "$@"\' --'
logger.info(f"DEV mode exec path: {exec_path}")
logger.info(f"Source directory: {src_dir}")
@@ -139,29 +139,43 @@ class NexusOAuthService:
else:
# Check if Exec path matches current mode
current_content = desktop_file.read_text()
if f"Exec={exec_path} %u" not in current_content:
# Check for both quoted (AppImage) and unquoted (DEV mode with bash -c) formats
if is_appimage:
expected_exec = f'Exec="{exec_path}" %u'
else:
expected_exec = f"Exec={exec_path} %u"
if expected_exec not in current_content:
needs_update = True
logger.info(f"Updating desktop file with new Exec path: {exec_path}")
# Explicitly detect and fix malformed entries (unquoted paths with spaces)
# Check if any Exec line exists without quotes but contains spaces
if is_appimage and ' ' in exec_path:
import re
# Look for Exec=<path with spaces> without quotes
if re.search(r'Exec=[^"]\S*\s+\S*\.AppImage', current_content):
needs_update = True
logger.info("Fixing malformed desktop file (unquoted path with spaces)")
if needs_update:
desktop_file.parent.mkdir(parents=True, exist_ok=True)
# Build desktop file content with proper working directory
if is_appimage:
# AppImage doesn't need working directory
# AppImage - quote path to handle spaces
desktop_content = f"""[Desktop Entry]
Type=Application
Name=Jackify
Comment=Wabbajack modlist manager for Linux
Exec={exec_path} %u
Exec="{exec_path}" %u
Icon=com.jackify.app
Terminal=false
Categories=Game;Utility;
MimeType=x-scheme-handler/jackify;
"""
else:
# DEV mode needs working directory set to src/
# exec_path already contains the correct format: "cd {src_dir} && {sys.executable} -m jackify.frontends.gui"
# DEV mode - exec_path already contains bash -c with proper quoting
src_dir = Path(__file__).parent.parent.parent.parent # Go up to src/
desktop_content = f"""[Desktop Entry]
Type=Application

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -7,7 +7,7 @@
"targets": {
".NETCoreApp,Version=v8.0": {},
".NETCoreApp,Version=v8.0/linux-x64": {
"jackify-engine/0.4.5": {
"jackify-engine/0.4.6": {
"dependencies": {
"Markdig": "0.40.0",
"Microsoft.Extensions.Configuration.Json": "9.0.1",
@@ -22,16 +22,16 @@
"SixLabors.ImageSharp": "3.1.6",
"System.CommandLine": "2.0.0-beta4.22272.1",
"System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1",
"Wabbajack.CLI.Builder": "0.4.5",
"Wabbajack.Downloaders.Bethesda": "0.4.5",
"Wabbajack.Downloaders.Dispatcher": "0.4.5",
"Wabbajack.Hashing.xxHash64": "0.4.5",
"Wabbajack.Networking.Discord": "0.4.5",
"Wabbajack.Networking.GitHub": "0.4.5",
"Wabbajack.Paths.IO": "0.4.5",
"Wabbajack.Server.Lib": "0.4.5",
"Wabbajack.Services.OSIntegrated": "0.4.5",
"Wabbajack.VFS": "0.4.5",
"Wabbajack.CLI.Builder": "0.4.6",
"Wabbajack.Downloaders.Bethesda": "0.4.6",
"Wabbajack.Downloaders.Dispatcher": "0.4.6",
"Wabbajack.Hashing.xxHash64": "0.4.6",
"Wabbajack.Networking.Discord": "0.4.6",
"Wabbajack.Networking.GitHub": "0.4.6",
"Wabbajack.Paths.IO": "0.4.6",
"Wabbajack.Server.Lib": "0.4.6",
"Wabbajack.Services.OSIntegrated": "0.4.6",
"Wabbajack.VFS": "0.4.6",
"MegaApiClient": "1.0.0.0",
"runtimepack.Microsoft.NETCore.App.Runtime.linux-x64": "8.0.22"
},
@@ -1781,7 +1781,7 @@
}
}
},
"Wabbajack.CLI.Builder/0.4.5": {
"Wabbajack.CLI.Builder/0.4.6": {
"dependencies": {
"Microsoft.Extensions.Configuration.Json": "9.0.1",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
@@ -1791,109 +1791,109 @@
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"System.CommandLine": "2.0.0-beta4.22272.1",
"System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1",
"Wabbajack.Paths": "0.4.5"
"Wabbajack.Paths": "0.4.6"
},
"runtime": {
"Wabbajack.CLI.Builder.dll": {}
}
},
"Wabbajack.Common/0.4.5": {
"Wabbajack.Common/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"System.Reactive": "6.0.1",
"Wabbajack.DTOs": "0.4.5",
"Wabbajack.Networking.Http": "0.4.5",
"Wabbajack.Paths.IO": "0.4.5"
"Wabbajack.DTOs": "0.4.6",
"Wabbajack.Networking.Http": "0.4.6",
"Wabbajack.Paths.IO": "0.4.6"
},
"runtime": {
"Wabbajack.Common.dll": {}
}
},
"Wabbajack.Compiler/0.4.5": {
"Wabbajack.Compiler/0.4.6": {
"dependencies": {
"F23.StringSimilarity": "6.0.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Downloaders.Dispatcher": "0.4.5",
"Wabbajack.Installer": "0.4.5",
"Wabbajack.VFS": "0.4.5",
"Wabbajack.Downloaders.Dispatcher": "0.4.6",
"Wabbajack.Installer": "0.4.6",
"Wabbajack.VFS": "0.4.6",
"ini-parser-netstandard": "2.5.2"
},
"runtime": {
"Wabbajack.Compiler.dll": {}
}
},
"Wabbajack.Compression.BSA/0.4.5": {
"Wabbajack.Compression.BSA/0.4.6": {
"dependencies": {
"K4os.Compression.LZ4.Streams": "1.3.8",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"SharpZipLib": "1.4.2",
"Wabbajack.Common": "0.4.5",
"Wabbajack.DTOs": "0.4.5"
"Wabbajack.Common": "0.4.6",
"Wabbajack.DTOs": "0.4.6"
},
"runtime": {
"Wabbajack.Compression.BSA.dll": {}
}
},
"Wabbajack.Compression.Zip/0.4.5": {
"Wabbajack.Compression.Zip/0.4.6": {
"dependencies": {
"Wabbajack.IO.Async": "0.4.5"
"Wabbajack.IO.Async": "0.4.6"
},
"runtime": {
"Wabbajack.Compression.Zip.dll": {}
}
},
"Wabbajack.Configuration/0.4.5": {
"Wabbajack.Configuration/0.4.6": {
"runtime": {
"Wabbajack.Configuration.dll": {}
}
},
"Wabbajack.Downloaders.Bethesda/0.4.5": {
"Wabbajack.Downloaders.Bethesda/0.4.6": {
"dependencies": {
"LibAES-CTR": "1.1.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"SharpZipLib": "1.4.2",
"Wabbajack.Common": "0.4.5",
"Wabbajack.Downloaders.Interfaces": "0.4.5",
"Wabbajack.Networking.BethesdaNet": "0.4.5"
"Wabbajack.Common": "0.4.6",
"Wabbajack.Downloaders.Interfaces": "0.4.6",
"Wabbajack.Networking.BethesdaNet": "0.4.6"
},
"runtime": {
"Wabbajack.Downloaders.Bethesda.dll": {}
}
},
"Wabbajack.Downloaders.Dispatcher/0.4.5": {
"Wabbajack.Downloaders.Dispatcher/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Downloaders.Bethesda": "0.4.5",
"Wabbajack.Downloaders.GameFile": "0.4.5",
"Wabbajack.Downloaders.GoogleDrive": "0.4.5",
"Wabbajack.Downloaders.Http": "0.4.5",
"Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.4.5",
"Wabbajack.Downloaders.Interfaces": "0.4.5",
"Wabbajack.Downloaders.Manual": "0.4.5",
"Wabbajack.Downloaders.MediaFire": "0.4.5",
"Wabbajack.Downloaders.Mega": "0.4.5",
"Wabbajack.Downloaders.ModDB": "0.4.5",
"Wabbajack.Downloaders.Nexus": "0.4.5",
"Wabbajack.Downloaders.VerificationCache": "0.4.5",
"Wabbajack.Downloaders.WabbajackCDN": "0.4.5",
"Wabbajack.Networking.WabbajackClientApi": "0.4.5"
"Wabbajack.Downloaders.Bethesda": "0.4.6",
"Wabbajack.Downloaders.GameFile": "0.4.6",
"Wabbajack.Downloaders.GoogleDrive": "0.4.6",
"Wabbajack.Downloaders.Http": "0.4.6",
"Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.4.6",
"Wabbajack.Downloaders.Interfaces": "0.4.6",
"Wabbajack.Downloaders.Manual": "0.4.6",
"Wabbajack.Downloaders.MediaFire": "0.4.6",
"Wabbajack.Downloaders.Mega": "0.4.6",
"Wabbajack.Downloaders.ModDB": "0.4.6",
"Wabbajack.Downloaders.Nexus": "0.4.6",
"Wabbajack.Downloaders.VerificationCache": "0.4.6",
"Wabbajack.Downloaders.WabbajackCDN": "0.4.6",
"Wabbajack.Networking.WabbajackClientApi": "0.4.6"
},
"runtime": {
"Wabbajack.Downloaders.Dispatcher.dll": {}
}
},
"Wabbajack.Downloaders.GameFile/0.4.5": {
"Wabbajack.Downloaders.GameFile/0.4.6": {
"dependencies": {
"GameFinder.StoreHandlers.EADesktop": "4.5.0",
"GameFinder.StoreHandlers.EGS": "4.5.0",
@@ -1903,360 +1903,360 @@
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Downloaders.Interfaces": "0.4.5",
"Wabbajack.VFS": "0.4.5"
"Wabbajack.Downloaders.Interfaces": "0.4.6",
"Wabbajack.VFS": "0.4.6"
},
"runtime": {
"Wabbajack.Downloaders.GameFile.dll": {}
}
},
"Wabbajack.Downloaders.GoogleDrive/0.4.5": {
"Wabbajack.Downloaders.GoogleDrive/0.4.6": {
"dependencies": {
"HtmlAgilityPack": "1.11.72",
"Microsoft.AspNetCore.Http.Extensions": "2.3.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.4.5",
"Wabbajack.DTOs": "0.4.5",
"Wabbajack.Downloaders.Interfaces": "0.4.5",
"Wabbajack.Networking.Http": "0.4.5",
"Wabbajack.Networking.Http.Interfaces": "0.4.5"
"Wabbajack.Common": "0.4.6",
"Wabbajack.DTOs": "0.4.6",
"Wabbajack.Downloaders.Interfaces": "0.4.6",
"Wabbajack.Networking.Http": "0.4.6",
"Wabbajack.Networking.Http.Interfaces": "0.4.6"
},
"runtime": {
"Wabbajack.Downloaders.GoogleDrive.dll": {}
}
},
"Wabbajack.Downloaders.Http/0.4.5": {
"Wabbajack.Downloaders.Http/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.4.5",
"Wabbajack.DTOs": "0.4.5",
"Wabbajack.Downloaders.Interfaces": "0.4.5",
"Wabbajack.Networking.BethesdaNet": "0.4.5",
"Wabbajack.Networking.Http.Interfaces": "0.4.5",
"Wabbajack.Paths.IO": "0.4.5"
"Wabbajack.Common": "0.4.6",
"Wabbajack.DTOs": "0.4.6",
"Wabbajack.Downloaders.Interfaces": "0.4.6",
"Wabbajack.Networking.BethesdaNet": "0.4.6",
"Wabbajack.Networking.Http.Interfaces": "0.4.6",
"Wabbajack.Paths.IO": "0.4.6"
},
"runtime": {
"Wabbajack.Downloaders.Http.dll": {}
}
},
"Wabbajack.Downloaders.Interfaces/0.4.5": {
"Wabbajack.Downloaders.Interfaces/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.Compression.Zip": "0.4.5",
"Wabbajack.DTOs": "0.4.5",
"Wabbajack.Paths.IO": "0.4.5"
"Wabbajack.Compression.Zip": "0.4.6",
"Wabbajack.DTOs": "0.4.6",
"Wabbajack.Paths.IO": "0.4.6"
},
"runtime": {
"Wabbajack.Downloaders.Interfaces.dll": {}
}
},
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.4.5": {
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.4.6": {
"dependencies": {
"F23.StringSimilarity": "6.0.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.4.5",
"Wabbajack.Downloaders.Interfaces": "0.4.5",
"Wabbajack.Networking.Http": "0.4.5",
"Wabbajack.Networking.Http.Interfaces": "0.4.5"
"Wabbajack.Common": "0.4.6",
"Wabbajack.Downloaders.Interfaces": "0.4.6",
"Wabbajack.Networking.Http": "0.4.6",
"Wabbajack.Networking.Http.Interfaces": "0.4.6"
},
"runtime": {
"Wabbajack.Downloaders.IPS4OAuth2Downloader.dll": {}
}
},
"Wabbajack.Downloaders.Manual/0.4.5": {
"Wabbajack.Downloaders.Manual/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.4.5",
"Wabbajack.Downloaders.Interfaces": "0.4.5"
"Wabbajack.Common": "0.4.6",
"Wabbajack.Downloaders.Interfaces": "0.4.6"
},
"runtime": {
"Wabbajack.Downloaders.Manual.dll": {}
}
},
"Wabbajack.Downloaders.MediaFire/0.4.5": {
"Wabbajack.Downloaders.MediaFire/0.4.6": {
"dependencies": {
"HtmlAgilityPack": "1.11.72",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.4.5",
"Wabbajack.Downloaders.Interfaces": "0.4.5",
"Wabbajack.Networking.Http.Interfaces": "0.4.5"
"Wabbajack.Common": "0.4.6",
"Wabbajack.Downloaders.Interfaces": "0.4.6",
"Wabbajack.Networking.Http.Interfaces": "0.4.6"
},
"runtime": {
"Wabbajack.Downloaders.MediaFire.dll": {}
}
},
"Wabbajack.Downloaders.Mega/0.4.5": {
"Wabbajack.Downloaders.Mega/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3",
"Wabbajack.Common": "0.4.5",
"Wabbajack.Downloaders.Interfaces": "0.4.5",
"Wabbajack.Paths.IO": "0.4.5"
"Wabbajack.Common": "0.4.6",
"Wabbajack.Downloaders.Interfaces": "0.4.6",
"Wabbajack.Paths.IO": "0.4.6"
},
"runtime": {
"Wabbajack.Downloaders.Mega.dll": {}
}
},
"Wabbajack.Downloaders.ModDB/0.4.5": {
"Wabbajack.Downloaders.ModDB/0.4.6": {
"dependencies": {
"HtmlAgilityPack": "1.11.72",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3",
"Wabbajack.Common": "0.4.5",
"Wabbajack.Downloaders.Interfaces": "0.4.5",
"Wabbajack.Networking.Http": "0.4.5",
"Wabbajack.Networking.Http.Interfaces": "0.4.5"
"Wabbajack.Common": "0.4.6",
"Wabbajack.Downloaders.Interfaces": "0.4.6",
"Wabbajack.Networking.Http": "0.4.6",
"Wabbajack.Networking.Http.Interfaces": "0.4.6"
},
"runtime": {
"Wabbajack.Downloaders.ModDB.dll": {}
}
},
"Wabbajack.Downloaders.Nexus/0.4.5": {
"Wabbajack.Downloaders.Nexus/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.4.5",
"Wabbajack.Downloaders.Interfaces": "0.4.5",
"Wabbajack.Hashing.xxHash64": "0.4.5",
"Wabbajack.Networking.Http": "0.4.5",
"Wabbajack.Networking.Http.Interfaces": "0.4.5",
"Wabbajack.Networking.NexusApi": "0.4.5",
"Wabbajack.Paths": "0.4.5"
"Wabbajack.DTOs": "0.4.6",
"Wabbajack.Downloaders.Interfaces": "0.4.6",
"Wabbajack.Hashing.xxHash64": "0.4.6",
"Wabbajack.Networking.Http": "0.4.6",
"Wabbajack.Networking.Http.Interfaces": "0.4.6",
"Wabbajack.Networking.NexusApi": "0.4.6",
"Wabbajack.Paths": "0.4.6"
},
"runtime": {
"Wabbajack.Downloaders.Nexus.dll": {}
}
},
"Wabbajack.Downloaders.VerificationCache/0.4.5": {
"Wabbajack.Downloaders.VerificationCache/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Stub.System.Data.SQLite.Core.NetStandard": "1.0.119",
"Wabbajack.DTOs": "0.4.5",
"Wabbajack.Paths.IO": "0.4.5"
"Wabbajack.DTOs": "0.4.6",
"Wabbajack.Paths.IO": "0.4.6"
},
"runtime": {
"Wabbajack.Downloaders.VerificationCache.dll": {}
}
},
"Wabbajack.Downloaders.WabbajackCDN/0.4.5": {
"Wabbajack.Downloaders.WabbajackCDN/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Microsoft.Toolkit.HighPerformance": "7.1.2",
"Wabbajack.Common": "0.4.5",
"Wabbajack.Downloaders.Interfaces": "0.4.5",
"Wabbajack.Networking.Http": "0.4.5",
"Wabbajack.RateLimiter": "0.4.5"
"Wabbajack.Common": "0.4.6",
"Wabbajack.Downloaders.Interfaces": "0.4.6",
"Wabbajack.Networking.Http": "0.4.6",
"Wabbajack.RateLimiter": "0.4.6"
},
"runtime": {
"Wabbajack.Downloaders.WabbajackCDN.dll": {}
}
},
"Wabbajack.DTOs/0.4.5": {
"Wabbajack.DTOs/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.Hashing.xxHash64": "0.4.5",
"Wabbajack.Paths": "0.4.5"
"Wabbajack.Hashing.xxHash64": "0.4.6",
"Wabbajack.Paths": "0.4.6"
},
"runtime": {
"Wabbajack.DTOs.dll": {}
}
},
"Wabbajack.FileExtractor/0.4.5": {
"Wabbajack.FileExtractor/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"OMODFramework": "3.0.1",
"Wabbajack.Common": "0.4.5",
"Wabbajack.Compression.BSA": "0.4.5",
"Wabbajack.Hashing.PHash": "0.4.5",
"Wabbajack.Paths": "0.4.5"
"Wabbajack.Common": "0.4.6",
"Wabbajack.Compression.BSA": "0.4.6",
"Wabbajack.Hashing.PHash": "0.4.6",
"Wabbajack.Paths": "0.4.6"
},
"runtime": {
"Wabbajack.FileExtractor.dll": {}
}
},
"Wabbajack.Hashing.PHash/0.4.5": {
"Wabbajack.Hashing.PHash/0.4.6": {
"dependencies": {
"BCnEncoder.Net.ImageSharp": "1.1.1",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Shipwreck.Phash": "0.5.0",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Common": "0.4.5",
"Wabbajack.DTOs": "0.4.5",
"Wabbajack.Paths": "0.4.5",
"Wabbajack.Paths.IO": "0.4.5"
"Wabbajack.Common": "0.4.6",
"Wabbajack.DTOs": "0.4.6",
"Wabbajack.Paths": "0.4.6",
"Wabbajack.Paths.IO": "0.4.6"
},
"runtime": {
"Wabbajack.Hashing.PHash.dll": {}
}
},
"Wabbajack.Hashing.xxHash64/0.4.5": {
"Wabbajack.Hashing.xxHash64/0.4.6": {
"dependencies": {
"Wabbajack.Paths": "0.4.5",
"Wabbajack.RateLimiter": "0.4.5"
"Wabbajack.Paths": "0.4.6",
"Wabbajack.RateLimiter": "0.4.6"
},
"runtime": {
"Wabbajack.Hashing.xxHash64.dll": {}
}
},
"Wabbajack.Installer/0.4.5": {
"Wabbajack.Installer/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3",
"Octopus.Octodiff": "2.0.548",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.DTOs": "0.4.5",
"Wabbajack.Downloaders.Dispatcher": "0.4.5",
"Wabbajack.Downloaders.GameFile": "0.4.5",
"Wabbajack.FileExtractor": "0.4.5",
"Wabbajack.Networking.WabbajackClientApi": "0.4.5",
"Wabbajack.Paths": "0.4.5",
"Wabbajack.Paths.IO": "0.4.5",
"Wabbajack.VFS": "0.4.5",
"Wabbajack.DTOs": "0.4.6",
"Wabbajack.Downloaders.Dispatcher": "0.4.6",
"Wabbajack.Downloaders.GameFile": "0.4.6",
"Wabbajack.FileExtractor": "0.4.6",
"Wabbajack.Networking.WabbajackClientApi": "0.4.6",
"Wabbajack.Paths": "0.4.6",
"Wabbajack.Paths.IO": "0.4.6",
"Wabbajack.VFS": "0.4.6",
"ini-parser-netstandard": "2.5.2"
},
"runtime": {
"Wabbajack.Installer.dll": {}
}
},
"Wabbajack.IO.Async/0.4.5": {
"Wabbajack.IO.Async/0.4.6": {
"runtime": {
"Wabbajack.IO.Async.dll": {}
}
},
"Wabbajack.Networking.BethesdaNet/0.4.5": {
"Wabbajack.Networking.BethesdaNet/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.4.5",
"Wabbajack.Networking.Http": "0.4.5",
"Wabbajack.Networking.Http.Interfaces": "0.4.5"
"Wabbajack.DTOs": "0.4.6",
"Wabbajack.Networking.Http": "0.4.6",
"Wabbajack.Networking.Http.Interfaces": "0.4.6"
},
"runtime": {
"Wabbajack.Networking.BethesdaNet.dll": {}
}
},
"Wabbajack.Networking.Discord/0.4.5": {
"Wabbajack.Networking.Discord/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Networking.Http.Interfaces": "0.4.5"
"Wabbajack.Networking.Http.Interfaces": "0.4.6"
},
"runtime": {
"Wabbajack.Networking.Discord.dll": {}
}
},
"Wabbajack.Networking.GitHub/0.4.5": {
"Wabbajack.Networking.GitHub/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Octokit": "14.0.0",
"Wabbajack.DTOs": "0.4.5",
"Wabbajack.Networking.Http.Interfaces": "0.4.5"
"Wabbajack.DTOs": "0.4.6",
"Wabbajack.Networking.Http.Interfaces": "0.4.6"
},
"runtime": {
"Wabbajack.Networking.GitHub.dll": {}
}
},
"Wabbajack.Networking.Http/0.4.5": {
"Wabbajack.Networking.Http/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Http": "9.0.1",
"Microsoft.Extensions.Logging": "9.0.1",
"Wabbajack.Configuration": "0.4.5",
"Wabbajack.Downloaders.Interfaces": "0.4.5",
"Wabbajack.Hashing.xxHash64": "0.4.5",
"Wabbajack.Networking.Http.Interfaces": "0.4.5",
"Wabbajack.Paths": "0.4.5",
"Wabbajack.Paths.IO": "0.4.5"
"Wabbajack.Configuration": "0.4.6",
"Wabbajack.Downloaders.Interfaces": "0.4.6",
"Wabbajack.Hashing.xxHash64": "0.4.6",
"Wabbajack.Networking.Http.Interfaces": "0.4.6",
"Wabbajack.Paths": "0.4.6",
"Wabbajack.Paths.IO": "0.4.6"
},
"runtime": {
"Wabbajack.Networking.Http.dll": {}
}
},
"Wabbajack.Networking.Http.Interfaces/0.4.5": {
"Wabbajack.Networking.Http.Interfaces/0.4.6": {
"dependencies": {
"Wabbajack.Hashing.xxHash64": "0.4.5"
"Wabbajack.Hashing.xxHash64": "0.4.6"
},
"runtime": {
"Wabbajack.Networking.Http.Interfaces.dll": {}
}
},
"Wabbajack.Networking.NexusApi/0.4.5": {
"Wabbajack.Networking.NexusApi/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.4.5",
"Wabbajack.Networking.Http": "0.4.5",
"Wabbajack.Networking.Http.Interfaces": "0.4.5",
"Wabbajack.Networking.WabbajackClientApi": "0.4.5"
"Wabbajack.DTOs": "0.4.6",
"Wabbajack.Networking.Http": "0.4.6",
"Wabbajack.Networking.Http.Interfaces": "0.4.6",
"Wabbajack.Networking.WabbajackClientApi": "0.4.6"
},
"runtime": {
"Wabbajack.Networking.NexusApi.dll": {}
}
},
"Wabbajack.Networking.WabbajackClientApi/0.4.5": {
"Wabbajack.Networking.WabbajackClientApi/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Octokit": "14.0.0",
"Wabbajack.Common": "0.4.5",
"Wabbajack.DTOs": "0.4.5",
"Wabbajack.Paths.IO": "0.4.5",
"Wabbajack.VFS.Interfaces": "0.4.5",
"Wabbajack.Common": "0.4.6",
"Wabbajack.DTOs": "0.4.6",
"Wabbajack.Paths.IO": "0.4.6",
"Wabbajack.VFS.Interfaces": "0.4.6",
"YamlDotNet": "16.3.0"
},
"runtime": {
"Wabbajack.Networking.WabbajackClientApi.dll": {}
}
},
"Wabbajack.Paths/0.4.5": {
"Wabbajack.Paths/0.4.6": {
"runtime": {
"Wabbajack.Paths.dll": {}
}
},
"Wabbajack.Paths.IO/0.4.5": {
"Wabbajack.Paths.IO/0.4.6": {
"dependencies": {
"Wabbajack.Paths": "0.4.5",
"Wabbajack.Paths": "0.4.6",
"shortid": "4.0.0"
},
"runtime": {
"Wabbajack.Paths.IO.dll": {}
}
},
"Wabbajack.RateLimiter/0.4.5": {
"Wabbajack.RateLimiter/0.4.6": {
"runtime": {
"Wabbajack.RateLimiter.dll": {}
}
},
"Wabbajack.Server.Lib/0.4.5": {
"Wabbajack.Server.Lib/0.4.6": {
"dependencies": {
"FluentFTP": "52.0.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
@@ -2264,58 +2264,58 @@
"Nettle": "3.0.0",
"Newtonsoft.Json": "13.0.3",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Common": "0.4.5",
"Wabbajack.Networking.Http.Interfaces": "0.4.5",
"Wabbajack.Services.OSIntegrated": "0.4.5"
"Wabbajack.Common": "0.4.6",
"Wabbajack.Networking.Http.Interfaces": "0.4.6",
"Wabbajack.Services.OSIntegrated": "0.4.6"
},
"runtime": {
"Wabbajack.Server.Lib.dll": {}
}
},
"Wabbajack.Services.OSIntegrated/0.4.5": {
"Wabbajack.Services.OSIntegrated/0.4.6": {
"dependencies": {
"DeviceId": "6.8.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Compiler": "0.4.5",
"Wabbajack.Downloaders.Dispatcher": "0.4.5",
"Wabbajack.Installer": "0.4.5",
"Wabbajack.Networking.BethesdaNet": "0.4.5",
"Wabbajack.Networking.Discord": "0.4.5",
"Wabbajack.VFS": "0.4.5"
"Wabbajack.Compiler": "0.4.6",
"Wabbajack.Downloaders.Dispatcher": "0.4.6",
"Wabbajack.Installer": "0.4.6",
"Wabbajack.Networking.BethesdaNet": "0.4.6",
"Wabbajack.Networking.Discord": "0.4.6",
"Wabbajack.VFS": "0.4.6"
},
"runtime": {
"Wabbajack.Services.OSIntegrated.dll": {}
}
},
"Wabbajack.VFS/0.4.5": {
"Wabbajack.VFS/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"SixLabors.ImageSharp": "3.1.6",
"System.Data.SQLite.Core": "1.0.119",
"Wabbajack.Common": "0.4.5",
"Wabbajack.FileExtractor": "0.4.5",
"Wabbajack.Hashing.PHash": "0.4.5",
"Wabbajack.Hashing.xxHash64": "0.4.5",
"Wabbajack.Paths": "0.4.5",
"Wabbajack.Paths.IO": "0.4.5",
"Wabbajack.VFS.Interfaces": "0.4.5"
"Wabbajack.Common": "0.4.6",
"Wabbajack.FileExtractor": "0.4.6",
"Wabbajack.Hashing.PHash": "0.4.6",
"Wabbajack.Hashing.xxHash64": "0.4.6",
"Wabbajack.Paths": "0.4.6",
"Wabbajack.Paths.IO": "0.4.6",
"Wabbajack.VFS.Interfaces": "0.4.6"
},
"runtime": {
"Wabbajack.VFS.dll": {}
}
},
"Wabbajack.VFS.Interfaces/0.4.5": {
"Wabbajack.VFS.Interfaces/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.4.5",
"Wabbajack.Hashing.xxHash64": "0.4.5",
"Wabbajack.Paths": "0.4.5"
"Wabbajack.DTOs": "0.4.6",
"Wabbajack.Hashing.xxHash64": "0.4.6",
"Wabbajack.Paths": "0.4.6"
},
"runtime": {
"Wabbajack.VFS.Interfaces.dll": {}
@@ -2332,7 +2332,7 @@
}
},
"libraries": {
"jackify-engine/0.4.5": {
"jackify-engine/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
@@ -3021,202 +3021,202 @@
"path": "yamldotnet/16.3.0",
"hashPath": "yamldotnet.16.3.0.nupkg.sha512"
},
"Wabbajack.CLI.Builder/0.4.5": {
"Wabbajack.CLI.Builder/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Common/0.4.5": {
"Wabbajack.Common/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Compiler/0.4.5": {
"Wabbajack.Compiler/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Compression.BSA/0.4.5": {
"Wabbajack.Compression.BSA/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Compression.Zip/0.4.5": {
"Wabbajack.Compression.Zip/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Configuration/0.4.5": {
"Wabbajack.Configuration/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Bethesda/0.4.5": {
"Wabbajack.Downloaders.Bethesda/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Dispatcher/0.4.5": {
"Wabbajack.Downloaders.Dispatcher/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.GameFile/0.4.5": {
"Wabbajack.Downloaders.GameFile/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.GoogleDrive/0.4.5": {
"Wabbajack.Downloaders.GoogleDrive/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Http/0.4.5": {
"Wabbajack.Downloaders.Http/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Interfaces/0.4.5": {
"Wabbajack.Downloaders.Interfaces/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.4.5": {
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Manual/0.4.5": {
"Wabbajack.Downloaders.Manual/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.MediaFire/0.4.5": {
"Wabbajack.Downloaders.MediaFire/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Mega/0.4.5": {
"Wabbajack.Downloaders.Mega/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.ModDB/0.4.5": {
"Wabbajack.Downloaders.ModDB/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Nexus/0.4.5": {
"Wabbajack.Downloaders.Nexus/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.VerificationCache/0.4.5": {
"Wabbajack.Downloaders.VerificationCache/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.WabbajackCDN/0.4.5": {
"Wabbajack.Downloaders.WabbajackCDN/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.DTOs/0.4.5": {
"Wabbajack.DTOs/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.FileExtractor/0.4.5": {
"Wabbajack.FileExtractor/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Hashing.PHash/0.4.5": {
"Wabbajack.Hashing.PHash/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Hashing.xxHash64/0.4.5": {
"Wabbajack.Hashing.xxHash64/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Installer/0.4.5": {
"Wabbajack.Installer/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.IO.Async/0.4.5": {
"Wabbajack.IO.Async/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.BethesdaNet/0.4.5": {
"Wabbajack.Networking.BethesdaNet/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.Discord/0.4.5": {
"Wabbajack.Networking.Discord/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.GitHub/0.4.5": {
"Wabbajack.Networking.GitHub/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.Http/0.4.5": {
"Wabbajack.Networking.Http/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.Http.Interfaces/0.4.5": {
"Wabbajack.Networking.Http.Interfaces/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.NexusApi/0.4.5": {
"Wabbajack.Networking.NexusApi/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.WabbajackClientApi/0.4.5": {
"Wabbajack.Networking.WabbajackClientApi/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Paths/0.4.5": {
"Wabbajack.Paths/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Paths.IO/0.4.5": {
"Wabbajack.Paths.IO/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.RateLimiter/0.4.5": {
"Wabbajack.RateLimiter/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Server.Lib/0.4.5": {
"Wabbajack.Server.Lib/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Services.OSIntegrated/0.4.5": {
"Wabbajack.Services.OSIntegrated/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.VFS/0.4.5": {
"Wabbajack.VFS/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.VFS.Interfaces/0.4.5": {
"Wabbajack.VFS.Interfaces/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""

Binary file not shown.

View File

@@ -0,0 +1,185 @@
"""
ENB Proton Compatibility Dialog
Shown when ENB is detected in a modlist installation to warn users
about Proton version requirements for ENB compatibility.
"""
import logging
from pathlib import Path
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QWidget,
QSpacerItem, QSizePolicy, QFrame, QApplication
)
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon, QFont
logger = logging.getLogger(__name__)
class ENBProtonDialog(QDialog):
"""
Dialog shown when ENB is detected, warning users about Proton version requirements.
Features:
- Clear warning about ENB compatibility
- Ordered list of recommended Proton versions
- Prominent display to ensure users see it
"""
def __init__(self, modlist_name: str, parent=None):
super().__init__(parent)
self.modlist_name = modlist_name
self.setWindowTitle("ENB Detected - Proton Version Required")
self.setWindowModality(Qt.ApplicationModal) # Modal to ensure user sees it
self.setFixedSize(600, 550) # Increased height to show full Proton version list and button spacing
self.setStyleSheet("QDialog { background: #181818; color: #fff; border-radius: 12px; }")
layout = QVBoxLayout(self)
layout.setSpacing(0)
layout.setContentsMargins(30, 30, 30, 30)
# --- Card background for content ---
card = QFrame(self)
card.setObjectName("enbCard")
card.setFrameShape(QFrame.StyledPanel)
card.setFrameShadow(QFrame.Raised)
card.setFixedWidth(540)
card.setMinimumHeight(400) # Increased to accommodate full Proton version list
card.setMaximumHeight(16777215) # Remove max height constraint to allow expansion
card_layout = QVBoxLayout(card)
card_layout.setSpacing(16)
card_layout.setContentsMargins(28, 28, 28, 28)
card.setStyleSheet(
"QFrame#enbCard { "
" background: #23272e; "
" border-radius: 12px; "
" border: 2px solid #e67e22; " # Orange border for warning
"}"
)
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding)
# Warning title (orange/warning color)
title_label = QLabel("ENB Detected")
title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet(
"QLabel { "
" font-size: 24px; "
" font-weight: 700; "
" color: #e67e22; " # Orange warning color
" margin-bottom: 4px; "
"}"
)
card_layout.addWidget(title_label)
# Main warning message
warning_text = (
f"If you plan on using ENB as part of <span style='color:#3fb7d6; font-weight:600;'>{self.modlist_name}</span>, "
f"you will need to use one of the following Proton versions, otherwise you will have issues running the modlist:"
)
warning_label = QLabel(warning_text)
warning_label.setAlignment(Qt.AlignCenter)
warning_label.setWordWrap(True)
warning_label.setStyleSheet(
"QLabel { "
" font-size: 14px; "
" color: #e0e0e0; "
" line-height: 1.5; "
" margin-bottom: 12px; "
" padding: 8px; "
"}"
)
warning_label.setTextFormat(Qt.RichText)
card_layout.addWidget(warning_label)
# Proton version list (in order of recommendation)
versions_text = (
"<div style='text-align: left; padding: 12px; background: #1a1d23; border-radius: 8px; margin: 8px 0;'>"
"<div style='font-size: 13px; color: #b0b0b0; margin-bottom: 8px;'><b style='color: #fff;'>(In order of recommendation)</b></div>"
"<div style='font-size: 14px; color: #fff; line-height: 1.8;'>"
"• <b style='color: #2ecc71;'>Proton-CachyOS</b><br/>"
"• <b style='color: #3498db;'>GE-Proton 10-14</b> or <b style='color: #3498db;'>lower</b><br/>"
"• <b style='color: #f39c12;'>Proton 9</b> from Valve"
"</div>"
"</div>"
)
versions_label = QLabel(versions_text)
versions_label.setAlignment(Qt.AlignLeft)
versions_label.setWordWrap(True)
versions_label.setStyleSheet(
"QLabel { "
" font-size: 14px; "
" color: #e0e0e0; "
" line-height: 1.6; "
" margin: 8px 0; "
"}"
)
versions_label.setTextFormat(Qt.RichText)
card_layout.addWidget(versions_label)
# Additional note
note_text = (
"<div style='font-size: 12px; color: #95a5a6; font-style: italic; margin-top: 8px;'>"
"Note: Valve's Proton 10 has known ENB compatibility issues."
"</div>"
)
note_label = QLabel(note_text)
note_label.setAlignment(Qt.AlignCenter)
note_label.setWordWrap(True)
note_label.setStyleSheet(
"QLabel { "
" font-size: 12px; "
" color: #95a5a6; "
" font-style: italic; "
" margin-top: 8px; "
"}"
)
note_label.setTextFormat(Qt.RichText)
card_layout.addWidget(note_label)
layout.addStretch()
layout.addWidget(card, alignment=Qt.AlignCenter)
layout.addSpacing(20) # Add spacing between card and button
# OK button
btn_row = QHBoxLayout()
btn_row.addStretch()
self.ok_btn = QPushButton("I Understand")
self.ok_btn.setStyleSheet(
"QPushButton { "
" background: #3fb7d6; "
" color: #fff; "
" border: none; "
" border-radius: 6px; "
" padding: 10px 24px; "
" font-size: 14px; "
" font-weight: 600; "
"}"
"QPushButton:hover { "
" background: #35a5c2; "
"}"
"QPushButton:pressed { "
" background: #2d8fa8; "
"}"
)
self.ok_btn.clicked.connect(self.accept)
btn_row.addWidget(self.ok_btn)
btn_row.addStretch()
layout.addLayout(btn_row)
# Set the Wabbajack icon if available
self._set_dialog_icon()
logger.info(f"ENBProtonDialog created for modlist: {modlist_name}")
def _set_dialog_icon(self):
"""Set the dialog icon to Wabbajack icon if available"""
try:
icon_path = Path(__file__).parent.parent.parent.parent.parent / "Files" / "wabbajack-icon.png"
if icon_path.exists():
icon = QIcon(str(icon_path))
self.setWindowIcon(icon)
except Exception as e:
logger.debug(f"Could not set dialog icon: {e}")

View File

@@ -54,6 +54,7 @@ class SuccessDialog(QDialog):
card.setFrameShadow(QFrame.Raised)
card.setFixedWidth(440)
card.setMinimumHeight(380)
card.setMaximumHeight(16777215) # Remove max height constraint to allow expansion
card_layout = QVBoxLayout(card)
card_layout.setSpacing(12)
card_layout.setContentsMargins(28, 28, 28, 28)
@@ -64,7 +65,7 @@ class SuccessDialog(QDialog):
" border: 1px solid #353a40; "
"}"
)
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding)
# Success title (less saturated green)
title_label = QLabel("Success!")
@@ -87,21 +88,22 @@ class SuccessDialog(QDialog):
else:
message_html = message_text
message_label = QLabel(message_html)
# Center the success message within the wider card for all screen sizes
message_label.setAlignment(Qt.AlignCenter)
message_label.setWordWrap(True)
message_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding)
message_label.setStyleSheet(
"QLabel { "
" font-size: 15px; "
" color: #e0e0e0; "
" line-height: 1.3; "
" margin-bottom: 6px; "
" max-width: 400px; "
" min-width: 200px; "
" word-wrap: break-word; "
"}"
)
message_label.setTextFormat(Qt.RichText)
card_layout.addWidget(message_label)
# Ensure the label itself is centered in the card layout and uses full width
card_layout.addWidget(message_label, alignment=Qt.AlignCenter)
# Time taken
time_label = QLabel(f"Completed in {self.time_taken}")
@@ -226,13 +228,13 @@ class SuccessDialog(QDialog):
base_message = ""
if self.workflow_type == "tuxborn":
base_message = f"You can now launch Tuxborn from Steam and enjoy your modded {game_display} experience!"
elif self.workflow_type == "install" and self.modlist_name == "Wabbajack":
base_message = "You can now launch Wabbajack from Steam and install modlists. Once the modlist install is complete, you can run \"Configure New Modlist\" in Jackify to complete the configuration for running the modlist on Linux."
else:
base_message = f"You can now launch {self.modlist_name} from Steam and enjoy your modded {game_display} experience!"
# Add GE-Proton recommendation
proton_note = "\n\nNOTE: If you experience ENB issues, consider using GE-Proton 10-14 instead of Valve's Proton 10 (known ENB compatibility issues)."
return base_message + proton_note
# Note: ENB-specific Proton warning is now shown in a separate dialog when ENB is detected
return base_message
def _update_countdown(self):
if self._countdown > 0:

View File

@@ -22,19 +22,19 @@ if '--env-diagnostic' in sys.argv:
print("Bundled Environment Diagnostic")
print("=" * 50)
# Check if we're running from a frozen bundle
is_frozen = getattr(sys, 'frozen', False)
meipass = getattr(sys, '_MEIPASS', None)
# Check if we're running as AppImage
is_appimage = 'APPIMAGE' in os.environ or 'APPDIR' in os.environ
appdir = os.environ.get('APPDIR')
print(f"Frozen: {is_frozen}")
print(f"_MEIPASS: {meipass}")
print(f"AppImage: {is_appimage}")
print(f"APPDIR: {appdir}")
# Capture environment data
env_data = {
'timestamp': datetime.now().isoformat(),
'context': 'appimage_runtime',
'frozen': is_frozen,
'meipass': meipass,
'appimage': is_appimage,
'appdir': appdir,
'python_executable': sys.executable,
'working_directory': os.getcwd(),
'sys_path': sys.path,
@@ -737,8 +737,14 @@ class SettingsDialog(QDialog):
# Get all available Proton versions
available_protons = WineUtils.scan_all_proton_versions()
# Add "Auto" option first
self.install_proton_dropdown.addItem("Auto (Recommended)", "auto")
# Check if any Proton versions were found
has_proton = len(available_protons) > 0
# Add "Auto" or "No Proton" option first based on detection
if has_proton:
self.install_proton_dropdown.addItem("Auto (Recommended)", "auto")
else:
self.install_proton_dropdown.addItem("No Proton Versions Detected", "none")
# Filter for fast Proton versions only
fast_protons = []
@@ -893,9 +899,29 @@ class SettingsDialog(QDialog):
jackify_data_dir = self.jackify_data_dir_edit.text().strip()
self.config_handler.set("jackify_data_dir", jackify_data_dir)
# Initialize with existing config values as fallback (prevents UnboundLocalError if auto-detection fails)
resolved_install_path = self.config_handler.get("proton_path", "")
resolved_install_version = self.config_handler.get("proton_version", "")
# Save Install Proton selection - resolve "auto" to actual path
selected_install_proton_path = self.install_proton_dropdown.currentData()
if selected_install_proton_path == "auto":
if selected_install_proton_path == "none":
# No Proton detected - warn user but allow saving other settings
MessageService.warning(
self,
"No Compatible Proton Installed",
"Jackify requires Proton 9.0+, Proton Experimental, or GE-Proton 10+ to install modlists.\n\n"
"To install Proton:\n"
"1. Install any Windows game in Steam (Proton downloads automatically), OR\n"
"2. Install GE-Proton using ProtonPlus or ProtonUp-Qt, OR\n"
"3. Download GE-Proton manually from:\n"
" https://github.com/GloriousEggroll/proton-ge-custom/releases\n\n"
"Your other settings will be saved, but modlist installation may not work without Proton.",
safety_level="medium"
)
logger.warning("No Proton detected - user warned, allowing save to proceed for other settings")
# Don't modify Proton config, but continue to save other settings
elif selected_install_proton_path == "auto":
# Resolve "auto" to actual best Proton path using unified detection
try:
from jackify.backend.handlers.wine_utils import WineUtils
@@ -1295,6 +1321,7 @@ class JackifyMainWindow(QMainWindow):
InstallModlistScreen, ConfigureNewModlistScreen, ConfigureExistingModlistScreen
)
from jackify.frontends.gui.screens.install_ttw import InstallTTWScreen
from jackify.frontends.gui.screens.wabbajack_installer import WabbajackInstallerScreen
self.main_menu = MainMenu(stacked_widget=self.stacked_widget, dev_mode=dev_mode)
self.feature_placeholder = FeaturePlaceholder(stacked_widget=self.stacked_widget)
@@ -1326,6 +1353,11 @@ class JackifyMainWindow(QMainWindow):
main_menu_index=0,
system_info=self.system_info
)
self.wabbajack_installer_screen = WabbajackInstallerScreen(
stacked_widget=self.stacked_widget,
additional_tasks_index=3,
system_info=self.system_info
)
# Let TTW screen request window resize for expand/collapse
try:
@@ -1346,6 +1378,11 @@ class JackifyMainWindow(QMainWindow):
self.configure_existing_modlist_screen.resize_request.connect(self._on_child_resize_request)
except Exception:
pass
# Let Wabbajack Installer screen request window resize for expand/collapse
try:
self.wabbajack_installer_screen.resize_request.connect(self._on_child_resize_request)
except Exception:
pass
# Add screens to stacked widget
self.stacked_widget.addWidget(self.main_menu) # Index 0: Main Menu
@@ -1355,7 +1392,8 @@ class JackifyMainWindow(QMainWindow):
self.stacked_widget.addWidget(self.install_modlist_screen) # Index 4: Install Modlist
self.stacked_widget.addWidget(self.install_ttw_screen) # Index 5: Install TTW
self.stacked_widget.addWidget(self.configure_new_modlist_screen) # Index 6: Configure New
self.stacked_widget.addWidget(self.configure_existing_modlist_screen) # Index 7: Configure Existing
self.stacked_widget.addWidget(self.wabbajack_installer_screen) # Index 7: Wabbajack Installer
self.stacked_widget.addWidget(self.configure_existing_modlist_screen) # Index 8: Configure Existing
# Add debug tracking for screen changes
self.stacked_widget.currentChanged.connect(self._debug_screen_change)
@@ -1828,10 +1866,6 @@ class JackifyMainWindow(QMainWindow):
def resource_path(relative_path):
"""Get path to resource file, handling both AppImage and dev modes."""
# PyInstaller frozen mode
if hasattr(sys, '_MEIPASS'):
return os.path.join(sys._MEIPASS, relative_path)
# AppImage mode - use APPDIR if available
appdir = os.environ.get('APPDIR')
if appdir:

View File

@@ -10,6 +10,7 @@ from .additional_tasks import AdditionalTasksScreen
from .install_modlist import InstallModlistScreen
from .configure_new_modlist import ConfigureNewModlistScreen
from .configure_existing_modlist import ConfigureExistingModlistScreen
from .wabbajack_installer import WabbajackInstallerScreen
__all__ = [
'MainMenu',
@@ -17,5 +18,6 @@ __all__ = [
'AdditionalTasksScreen',
'InstallModlistScreen',
'ConfigureNewModlistScreen',
'ConfigureExistingModlistScreen'
'ConfigureExistingModlistScreen',
'WabbajackInstallerScreen'
]

View File

@@ -65,7 +65,7 @@ class AdditionalTasksScreen(QWidget):
header_layout.addSpacing(10)
# Description area with fixed height
desc = QLabel("TTW automation and additional tools.")
desc = QLabel("TTW automation, Wabbajack installer, and additional tools.")
desc.setWordWrap(True)
desc.setStyleSheet("color: #ccc; font-size: 13px;")
desc.setAlignment(Qt.AlignHCenter)
@@ -89,10 +89,10 @@ class AdditionalTasksScreen(QWidget):
def _setup_menu_buttons(self, layout):
"""Set up the menu buttons section"""
# Menu options - ONLY TTW and placeholder
# Menu options
MENU_ITEMS = [
("Install TTW", "ttw_install", "Install Tale of Two Wastelands using TTW_Linux_Installer"),
("Coming Soon...", "coming_soon", "Additional tools will be added in future updates"),
("Install Wabbajack", "wabbajack_install", "Install Wabbajack.exe via Proton (automated setup)"),
("Return to Main Menu", "return_main_menu", "Go back to the main menu"),
]
@@ -146,6 +146,8 @@ class AdditionalTasksScreen(QWidget):
"""Handle button clicks"""
if action_id == "ttw_install":
self._show_ttw_info()
elif action_id == "wabbajack_install":
self._show_wabbajack_installer()
elif action_id == "coming_soon":
self._show_coming_soon_info()
elif action_id == "return_main_menu":
@@ -157,6 +159,12 @@ class AdditionalTasksScreen(QWidget):
# Navigate to TTW installation screen (index 5)
self.stacked_widget.setCurrentIndex(5)
def _show_wabbajack_installer(self):
"""Navigate to Wabbajack installer screen"""
if self.stacked_widget:
# Navigate to Wabbajack installer screen (index 7)
self.stacked_widget.setCurrentIndex(7)
def _show_coming_soon_info(self):
"""Show coming soon info"""
from ..services.message_service import MessageService

View File

@@ -1,6 +1,7 @@
"""
ConfigureNewModlistScreen for Jackify GUI
"""
import logging
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QCheckBox, QMainWindow
from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject
from PySide6.QtGui import QPixmap, QTextCursor
@@ -28,6 +29,8 @@ from PySide6.QtWidgets import QApplication
from jackify.frontends.gui.services.message_service import MessageService
from jackify.shared.resolution_utils import get_resolution_fallback
logger = logging.getLogger(__name__)
def debug_print(message):
"""Print debug message only if debug mode is enabled"""
from jackify.backend.handlers.config_handler import ConfigHandler
@@ -97,7 +100,6 @@ class SelectionDialog(QDialog):
self.accept()
class ConfigureNewModlistScreen(QWidget):
steam_restart_finished = Signal(bool, str)
resize_request = Signal(str)
def __init__(self, stacked_widget=None, main_menu_index=0):
super().__init__()
@@ -426,8 +428,6 @@ class ConfigureNewModlistScreen(QWidget):
self.top_timer.start(2000)
# --- Start Configuration button ---
self.start_btn.clicked.connect(self.validate_and_start_configure)
# --- Connect steam_restart_finished signal ---
self.steam_restart_finished.connect(self._on_steam_restart_finished)
# Initialize empty controls list - will be populated after UI is built
self._actionable_controls = []
@@ -852,34 +852,7 @@ class ConfigureNewModlistScreen(QWidget):
MessageService.warning(self, "Missing Name", "Please specify a name for your modlist", safety_level="low")
self._enable_controls_after_operation()
return
# --- Shortcut creation will be handled by automated workflow ---
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
from jackify.backend.services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
steamdeck = platform_service.is_steamdeck
shortcut_handler = ShortcutHandler(steamdeck=steamdeck) # Still needed for Steam restart
# Check if auto-restart is enabled
auto_restart_enabled = hasattr(self, 'auto_restart_checkbox') and self.auto_restart_checkbox.isChecked()
if auto_restart_enabled:
# Auto-accept Steam restart - proceed without dialog
self._safe_append_text("Auto-accepting Steam restart (unattended mode enabled)")
reply = QMessageBox.Yes # Simulate user clicking Yes
else:
# --- User confirmation before restarting Steam ---
reply = MessageService.question(
self, "Ready to Configure Modlist",
"Would you like to restart Steam and begin post-install configuration now? Restarting Steam could close any games you have open!",
safety_level="medium"
)
debug_print(f"DEBUG: Steam restart dialog returned: {reply!r}")
if reply not in (QMessageBox.Yes, QMessageBox.Ok, QMessageBox.AcceptRole):
self._enable_controls_after_operation()
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(0)
return
# Handle resolution saving
resolution = self.resolution_combo.currentText()
if resolution and resolution != "Leave unchanged":
@@ -893,41 +866,9 @@ class ConfigureNewModlistScreen(QWidget):
if self.resolution_service.has_saved_resolution():
self.resolution_service.clear_saved_resolution()
debug_print("DEBUG: Saved resolution cleared")
# --- Steam Configuration (progress dialog, thread, and signal) ---
progress = QProgressDialog("Steam Configuration...", None, 0, 0, self)
progress.setWindowTitle("Steam Configuration")
progress.setWindowModality(Qt.WindowModal)
progress.setMinimumDuration(0)
progress.setValue(0)
progress.show()
def do_restart():
try:
ok = shortcut_handler.secure_steam_restart()
out = ''
except Exception as e:
ok = False
out = str(e)
self._safe_append_text(f"[ERROR] Exception during Steam restart: {e}")
self.steam_restart_finished.emit(ok, out)
threading.Thread(target=do_restart, daemon=True).start()
self._steam_restart_progress = progress
def _on_steam_restart_finished(self, success, out):
if hasattr(self, '_steam_restart_progress'):
self._steam_restart_progress.close()
del self._steam_restart_progress
self._enable_controls_after_operation()
if success:
self._safe_append_text("Steam restarted successfully.")
# Start configuration immediately - the CLI will handle any manual steps
from jackify import __version__ as jackify_version
self._safe_append_text(f"Jackify v{jackify_version}")
self._safe_append_text("Starting modlist configuration...")
self.configure_modlist()
else:
self._safe_append_text("Failed to restart Steam.\n" + str(out))
MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.", safety_level="medium")
# Start configuration - automated workflow handles Steam restart internally
self.configure_modlist()
def configure_modlist(self):
# CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog
@@ -1061,6 +1002,16 @@ class ConfigureNewModlistScreen(QWidget):
"""Handle error from the automated prefix workflow"""
self._safe_append_text(f"Error during automated Steam setup: {error_message}")
self._safe_append_text("Please check the logs for details.")
# Show critical error dialog to user (don't silently fail)
from jackify.backend.services.message_service import MessageService
MessageService.critical(
self,
"Steam Setup Error",
f"Error during automated Steam setup:\n\n{error_message}\n\nPlease check the console output for details.",
safety_level="medium"
)
self._enable_controls_after_operation()
def show_shortcut_conflict_dialog(self, conflicts):
@@ -1331,7 +1282,7 @@ class ConfigureNewModlistScreen(QWidget):
# Create new config thread with updated context
class ConfigThread(QThread):
progress_update = Signal(str)
configuration_complete = Signal(bool, str, str)
configuration_complete = Signal(bool, str, str, bool)
error_occurred = Signal(str)
def __init__(self, context):
@@ -1369,8 +1320,8 @@ class ConfigureNewModlistScreen(QWidget):
def progress_callback(message):
self.progress_update.emit(message)
def completion_callback(success, message, modlist_name):
self.configuration_complete.emit(success, message, modlist_name)
def completion_callback(success, message, modlist_name, enb_detected=False):
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
def manual_steps_callback(modlist_name, retry_count):
# This shouldn't happen since automated prefix creation is complete
@@ -1432,7 +1383,7 @@ class ConfigureNewModlistScreen(QWidget):
class ConfigThread(QThread):
progress_update = Signal(str)
configuration_complete = Signal(bool, str, str)
configuration_complete = Signal(bool, str, str, bool)
error_occurred = Signal(str)
def __init__(self, context):
@@ -1471,8 +1422,8 @@ class ConfigureNewModlistScreen(QWidget):
def progress_callback(message):
self.progress_update.emit(message)
def completion_callback(success, message, modlist_name):
self.configuration_complete.emit(success, message, modlist_name)
def completion_callback(success, message, modlist_name, enb_detected=False):
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
def manual_steps_callback(modlist_name, retry_count):
# This shouldn't happen since manual steps should be done
@@ -1507,7 +1458,7 @@ class ConfigureNewModlistScreen(QWidget):
self._safe_append_text(f"Error continuing configuration: {e}")
MessageService.critical(self, "Configuration Error", f"Failed to continue configuration: {e}", safety_level="medium")
def on_configuration_complete(self, success, message, modlist_name):
def on_configuration_complete(self, success, message, modlist_name, enb_detected=False):
"""Handle configuration completion (same as Tuxborn)"""
# Re-enable all controls when workflow completes
self._enable_controls_after_operation()
@@ -1528,6 +1479,16 @@ class ConfigureNewModlistScreen(QWidget):
parent=self
)
success_dialog.show()
# Show ENB Proton dialog if ENB was detected (use stored detection result, no re-detection)
if enb_detected:
try:
from ..dialogs.enb_proton_dialog import ENBProtonDialog
enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self)
enb_dialog.exec() # Modal dialog - blocks until user clicks OK
except Exception as e:
# Non-blocking: if dialog fails, just log and continue
logger.warning(f"Failed to show ENB dialog: {e}")
else:
self._safe_append_text(f"Configuration failed: {message}")
MessageService.critical(self, "Configuration Failed",

View File

@@ -30,7 +30,7 @@ from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicato
from jackify.backend.handlers.progress_parser import ProgressStateManager
from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
from jackify.shared.progress_models import InstallationPhase, InstallationProgress
from jackify.shared.progress_models import InstallationPhase, InstallationProgress, OperationType
# Modlist gallery (imported at module level to avoid import delay when opening dialog)
from jackify.frontends.gui.screens.modlist_gallery import ModlistGalleryDialog
@@ -409,6 +409,8 @@ class InstallModlistScreen(QWidget):
self.file_progress_list = FileProgressList() # Shows all active files (scrolls if needed)
self._premium_notice_shown = False
self._premium_failure_active = False
self._stalled_download_start_time = None # Track when downloads stall
self._stalled_download_notified = False
self._post_install_sequence = self._build_post_install_sequence()
self._post_install_total_steps = len(self._post_install_sequence)
self._post_install_current_step = 0
@@ -2065,6 +2067,9 @@ class InstallModlistScreen(QWidget):
self.file_progress_list.clear()
self.file_progress_list.start_cpu_tracking() # Start tracking CPU during installation
self._premium_notice_shown = False
self._stalled_download_start_time = None # Reset stall detection
self._stalled_download_notified = False
self._token_error_notified = False # Reset token error notification
self._premium_failure_active = False
self._post_install_active = False
self._post_install_current_step = 0
@@ -2203,6 +2208,10 @@ class InstallModlistScreen(QWidget):
env_vars = {'NEXUS_API_KEY': self.api_key}
if self.oauth_info:
env_vars['NEXUS_OAUTH_INFO'] = self.oauth_info
# CRITICAL: Set client_id so engine can refresh tokens with correct client_id
# Engine's RefreshToken method reads this to use our "jackify" client_id instead of hardcoded "wabbajack"
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
env_vars['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
env = get_clean_subprocess_env(env_vars)
self.process_manager = ProcessManager(cmd, env=env, text=False)
ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]')
@@ -2479,8 +2488,54 @@ class InstallModlistScreen(QWidget):
self._write_to_log_file(message)
return
# Detect known engine bugs and provide helpful guidance
# CRITICAL: Detect token/auth errors and ALWAYS show them (even when not in debug mode)
msg_lower = message.lower()
token_error_keywords = [
'token has expired',
'token expired',
'oauth token',
'authentication failed',
'unauthorized',
'401',
'403',
'refresh token',
'authorization failed',
'nexus.*premium.*required',
'premium.*required',
]
is_token_error = any(keyword in msg_lower for keyword in token_error_keywords)
if is_token_error:
# CRITICAL ERROR - always show, even if console is hidden
if not hasattr(self, '_token_error_notified'):
self._token_error_notified = True
# Show error dialog immediately
from jackify.frontends.gui.services.message_service import MessageService
MessageService.error(
self,
"Authentication Error",
(
"Nexus Mods authentication has failed. This may be due to:\n\n"
"• OAuth token expired and refresh failed\n"
"• Nexus Premium required for this modlist\n"
"• Network connectivity issues\n\n"
"Please check the console output (Show Details) for more information.\n"
"You may need to re-authorize in Settings."
),
safety_level="high"
)
# Also show in console
guidance = (
"\n[Jackify] ⚠️ CRITICAL: Authentication/Token Error Detected!\n"
"[Jackify] This may cause downloads to stop. Check the error message above.\n"
"[Jackify] If OAuth token expired, go to Settings and re-authorize.\n"
)
self._safe_append_text(guidance)
# Force console to be visible so user can see the error
if not self.show_details_checkbox.isChecked():
self.show_details_checkbox.setChecked(True)
# Detect known engine bugs and provide helpful guidance
if 'destination array was not long enough' in msg_lower or \
('argumentexception' in msg_lower and 'downloadmachineurl' in msg_lower):
# This is a known bug in jackify-engine 0.4.0 during .wabbajack download
@@ -2544,6 +2599,62 @@ class InstallModlistScreen(QWidget):
bsa_percent = (progress_state.bsa_building_current / progress_state.bsa_building_total) * 100.0
progress_state.overall_percent = min(99.0, bsa_percent) # Cap at 99% until fully complete
# CRITICAL: Detect stalled downloads (0.0MB/s for extended period)
# This catches cases where token refresh fails silently or network issues occur
# IMPORTANT: Only check during DOWNLOAD phase, not during VALIDATE phase
# Validation checks existing files and shows 0.0MB/s, which is expected behavior
import time
if progress_state.phase == InstallationPhase.DOWNLOAD:
speed_display = progress_state.get_overall_speed_display()
# Check if speed is 0 or very low (< 0.1MB/s) for more than 2 minutes
# Only trigger if we're actually in download phase (not validation)
is_stalled = not speed_display or speed_display == "0.0B/s" or \
(speed_display and any(x in speed_display.lower() for x in ['0.0mb/s', '0.0kb/s', '0b/s']))
# Additional check: Only consider it stalled if we have active download files
# If no files are being downloaded, it might just be between downloads
has_active_downloads = any(
f.operation == OperationType.DOWNLOAD and not f.is_complete
for f in progress_state.active_files
)
if is_stalled and has_active_downloads:
if self._stalled_download_start_time is None:
self._stalled_download_start_time = time.time()
else:
stalled_duration = time.time() - self._stalled_download_start_time
# Warn after 2 minutes of stalled downloads
if stalled_duration > 120 and not self._stalled_download_notified:
self._stalled_download_notified = True
from jackify.frontends.gui.services.message_service import MessageService
MessageService.warning(
self,
"Download Stalled",
(
"Downloads have been stalled (0.0MB/s) for over 2 minutes.\n\n"
"Possible causes:\n"
"• OAuth token expired and refresh failed\n"
"• Network connectivity issues\n"
"• Nexus Mods server issues\n\n"
"Please check the console output (Show Details) for error messages.\n"
"If authentication failed, you may need to re-authorize in Settings."
),
safety_level="low"
)
# Force console to be visible
if not self.show_details_checkbox.isChecked():
self.show_details_checkbox.setChecked(True)
# Add warning to console
self._safe_append_text(
"\n[Jackify] ⚠️ WARNING: Downloads have stalled (0.0MB/s for 2+ minutes)\n"
"[Jackify] This may indicate an authentication or network issue.\n"
"[Jackify] Check the console above for error messages.\n"
)
else:
# Downloads are active - reset stall timer
self._stalled_download_start_time = None
self._stalled_download_notified = False
# Update progress indicator widget
self.progress_indicator.update_progress(progress_state)
@@ -3748,7 +3859,7 @@ class InstallModlistScreen(QWidget):
self.steam_restart_progress = None
# Controls are managed by the proper control management system
def on_configuration_complete(self, success, message, modlist_name):
def on_configuration_complete(self, success, message, modlist_name, enb_detected=False):
"""Handle configuration completion on main thread"""
try:
# Stop CPU tracking now that everything is complete
@@ -3819,6 +3930,16 @@ class InstallModlistScreen(QWidget):
parent=self
)
success_dialog.show()
# Show ENB Proton dialog if ENB was detected (use stored detection result, no re-detection)
if enb_detected:
try:
from ..dialogs.enb_proton_dialog import ENBProtonDialog
enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self)
enb_dialog.exec() # Modal dialog - blocks until user clicks OK
except Exception as e:
# Non-blocking: if dialog fails, just log and continue
logger.warning(f"Failed to show ENB dialog: {e}")
elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3:
# Max retries reached - show failure message
MessageService.critical(self, "Manual Steps Failed",
@@ -4176,7 +4297,7 @@ class InstallModlistScreen(QWidget):
# Create new config thread with updated context
class ConfigThread(QThread):
progress_update = Signal(str)
configuration_complete = Signal(bool, str, str)
configuration_complete = Signal(bool, str, str, bool)
error_occurred = Signal(str)
def __init__(self, context, is_steamdeck):
@@ -4216,8 +4337,8 @@ class InstallModlistScreen(QWidget):
def progress_callback(message):
self.progress_update.emit(message)
def completion_callback(success, message, modlist_name):
self.configuration_complete.emit(success, message, modlist_name)
def completion_callback(success, message, modlist_name, enb_detected=False):
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
def manual_steps_callback(modlist_name, retry_count):
# This shouldn't happen since automated prefix creation is complete

View File

@@ -2502,7 +2502,7 @@ class InstallTTWScreen(QWidget):
self.steam_restart_progress = None
# Controls are managed by the proper control management system
def on_configuration_complete(self, success, message, modlist_name):
def on_configuration_complete(self, success, message, modlist_name, enb_detected=False):
"""Handle configuration completion on main thread"""
try:
# Re-enable controls now that installation/configuration is complete
@@ -2539,6 +2539,8 @@ class InstallTTWScreen(QWidget):
parent=self
)
success_dialog.show()
# Note: TTW workflow does NOT need ENB detection/dialog
elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3:
# Max retries reached - show failure message
MessageService.critical(self, "Manual Steps Failed",
@@ -2935,8 +2937,8 @@ class InstallTTWScreen(QWidget):
def progress_callback(message):
self.progress_update.emit(message)
def completion_callback(success, message, modlist_name):
self.configuration_complete.emit(success, message, modlist_name)
def completion_callback(success, message, modlist_name, enb_detected=False):
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
def manual_steps_callback(modlist_name, retry_count):
# This shouldn't happen since automated prefix creation is complete

View File

@@ -0,0 +1,714 @@
"""
Wabbajack Installer Screen
Automated Wabbajack.exe installation via Proton with progress tracking.
Follows standard Jackify screen layout.
"""
import logging
from pathlib import Path
from typing import Optional
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QFileDialog, QLineEdit, QGridLayout, QTextEdit, QTabWidget, QSizePolicy, QCheckBox
)
from PySide6.QtCore import Qt, QThread, Signal, QSize
from PySide6.QtGui import QTextCursor
from jackify.backend.models.configuration import SystemInfo
from jackify.backend.handlers.wabbajack_installer_handler import WabbajackInstallerHandler
from ..services.message_service import MessageService
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from ..utils import set_responsive_minimum
from ..widgets.file_progress_list import FileProgressList
from ..widgets.progress_indicator import OverallProgressIndicator
logger = logging.getLogger(__name__)
class WabbajackInstallerWorker(QThread):
"""Background worker for Wabbajack installation"""
progress_update = Signal(str, int) # Status message, percentage
activity_update = Signal(str, int, int) # Activity label, current, total
log_output = Signal(str) # Console log output
installation_complete = Signal(bool, str, str, str, str) # Success, message, launch_options, app_id, time_taken
def __init__(self, install_folder: Path, shortcut_name: str = "Wabbajack", enable_gog: bool = True):
super().__init__()
self.install_folder = install_folder
self.shortcut_name = shortcut_name
self.enable_gog = enable_gog
self.handler = WabbajackInstallerHandler()
self.launch_options = "" # Store launch options for success message
self.start_time = None # Track installation start time
def _log(self, message: str):
"""Emit log message"""
self.log_output.emit(message)
logger.info(message)
def run(self):
"""Run the installation workflow"""
import time
self.start_time = time.time()
try:
total_steps = 12
# Step 1: Check requirements
self.progress_update.emit("Checking requirements...", 5)
self.activity_update.emit("Checking requirements", 1, total_steps)
self._log("Checking system requirements...")
proton_path = self.handler.find_proton_experimental()
if not proton_path:
self.installation_complete.emit(
False,
"Proton Experimental not found.\nPlease install it from Steam."
)
return
self._log(f"Found Proton Experimental: {proton_path}")
userdata = self.handler.find_steam_userdata_path()
if not userdata:
self.installation_complete.emit(
False,
"Steam userdata not found.\nPlease ensure Steam is installed and you're logged in."
)
return
self._log(f"Found Steam userdata: {userdata}")
# Step 2: Download Wabbajack
self.progress_update.emit("Downloading Wabbajack.exe...", 15)
self.activity_update.emit("Downloading Wabbajack.exe", 2, total_steps)
self._log("Downloading Wabbajack.exe from GitHub...")
wabbajack_exe = self.handler.download_wabbajack(self.install_folder)
self._log(f"Downloaded to: {wabbajack_exe}")
# Step 3: Create dotnet cache
self.progress_update.emit("Creating .NET cache directory...", 20)
self.activity_update.emit("Creating .NET cache", 3, total_steps)
self._log("Creating .NET bundle extract cache...")
self.handler.create_dotnet_cache(self.install_folder)
# Step 4: Stop Steam before modifying shortcuts.vdf
self.progress_update.emit("Stopping Steam...", 25)
self.activity_update.emit("Stopping Steam", 4, total_steps)
self._log("Stopping Steam (required to safely modify shortcuts.vdf)...")
import subprocess
import time
# Kill Steam using pkill (simple approach like AuCu)
try:
subprocess.run(['steam', '-shutdown'], timeout=5, capture_output=True)
time.sleep(2)
subprocess.run(['pkill', '-9', 'steam'], timeout=5, capture_output=True)
time.sleep(2)
self._log("Steam stopped successfully")
except Exception as e:
self._log(f"Warning: Steam shutdown had issues: {e}. Proceeding anyway...")
# Step 5: Add to Steam shortcuts (NO Proton - like AuCu, but with STEAM_COMPAT_MOUNTS for libraries)
self.progress_update.emit("Adding to Steam shortcuts...", 30)
self.activity_update.emit("Adding to Steam", 5, total_steps)
self._log("Adding Wabbajack to Steam shortcuts...")
from jackify.backend.services.native_steam_service import NativeSteamService
steam_service = NativeSteamService()
# Generate launch options with STEAM_COMPAT_MOUNTS for additional Steam libraries (like modlist installs)
# Default to empty string (like AuCu) - only add options if we have additional libraries
# Note: Users may need to manually add other paths (e.g., download directories on different drives) to launch options
launch_options = ""
try:
from jackify.backend.handlers.path_handler import PathHandler
path_handler = PathHandler()
all_libs = path_handler.get_all_steam_library_paths()
main_steam_lib_path_obj = path_handler.find_steam_library()
if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common":
main_steam_lib_path = main_steam_lib_path_obj.parent.parent
filtered_libs = [lib for lib in all_libs if str(lib) != str(main_steam_lib_path)]
if filtered_libs:
mount_paths = ":".join(str(lib) for lib in filtered_libs)
launch_options = f'STEAM_COMPAT_MOUNTS="{mount_paths}" %command%'
self._log(f"Added STEAM_COMPAT_MOUNTS for additional Steam libraries: {mount_paths}")
else:
self._log("No additional Steam libraries found - using empty launch options (like AuCu)")
except Exception as e:
self._log(f"Could not generate STEAM_COMPAT_MOUNTS (non-critical): {e}")
# Keep empty string like AuCu
# Store launch options for success message
self.launch_options = launch_options
# Create shortcut WITHOUT Proton (AuCu does this separately later)
success, app_id = steam_service.create_shortcut(
app_name=self.shortcut_name,
exe_path=str(wabbajack_exe),
start_dir=str(wabbajack_exe.parent),
launch_options=launch_options, # Empty or with STEAM_COMPAT_MOUNTS
tags=["Jackify"]
)
if not success or app_id is None:
raise RuntimeError("Failed to create Steam shortcut")
self._log(f"Created Steam shortcut with AppID: {app_id}")
# Step 6: Initialize Wine prefix
self.progress_update.emit("Initializing Wine prefix...", 45)
self.activity_update.emit("Initializing Wine prefix", 6, total_steps)
self._log("Initializing Wine prefix with Proton...")
prefix_path = self.handler.init_wine_prefix(app_id)
self._log(f"Wine prefix created: {prefix_path}")
# Step 7: Install WebView2
self.progress_update.emit("Installing WebView2 runtime...", 60)
self.activity_update.emit("Installing WebView2", 7, total_steps)
self._log("Downloading and installing WebView2...")
try:
self.handler.install_webview2(app_id, self.install_folder)
self._log("WebView2 installed successfully")
except Exception as e:
self._log(f"WARNING: WebView2 installation may have failed: {e}")
self._log("This may prevent Nexus login in Wabbajack. You can manually install WebView2 later.")
# Continue installation - WebView2 is not critical for basic functionality
# Step 8: Apply Win7 registry
self.progress_update.emit("Applying Windows 7 registry settings...", 75)
self.activity_update.emit("Applying registry settings", 8, total_steps)
self._log("Applying Windows 7 compatibility settings...")
self.handler.apply_win7_registry(app_id)
self._log("Registry settings applied")
# Step 9: GOG game detection (optional)
gog_count = 0
if self.enable_gog:
self.progress_update.emit("Detecting GOG games from Heroic...", 80)
self.activity_update.emit("Detecting GOG games", 9, total_steps)
self._log("Searching for GOG games in Heroic...")
try:
gog_count = self.handler.inject_gog_registry(app_id)
if gog_count > 0:
self._log(f"Detected and injected {gog_count} GOG games")
else:
self._log("No GOG games found in Heroic")
except Exception as e:
self._log(f"GOG injection failed (non-critical): {e}")
# Step 10: Create Steam library symlinks
self.progress_update.emit("Creating Steam library symlinks...", 85)
self.activity_update.emit("Creating library symlinks", 10, total_steps)
self._log("Creating Steam library symlinks for game detection...")
steam_service.create_steam_library_symlinks(app_id)
self._log("Steam library symlinks created")
# Step 11: Set Proton Experimental (separate step like AuCu)
self.progress_update.emit("Setting Proton compatibility...", 90)
self.activity_update.emit("Setting Proton compatibility", 11, total_steps)
self._log("Setting Proton Experimental as compatibility tool...")
try:
steam_service.set_proton_version(app_id, "proton_experimental")
self._log("Proton Experimental set successfully")
except Exception as e:
self._log(f"Warning: Failed to set Proton version (non-critical): {e}")
self._log("You can set it manually in Steam: Properties → Compatibility → Proton Experimental")
# Step 12: Start Steam at the end
self.progress_update.emit("Starting Steam...", 95)
self.activity_update.emit("Starting Steam", 12, total_steps)
self._log("Starting Steam...")
from jackify.backend.services.steam_restart_service import start_steam
start_steam()
time.sleep(3) # Give Steam time to start
self._log("Steam started successfully")
# Done!
self.progress_update.emit("Installation complete!", 100)
self.activity_update.emit("Installation complete", 12, total_steps)
self._log("\n=== Installation Complete ===")
self._log(f"Wabbajack installed to: {self.install_folder}")
self._log(f"Steam AppID: {app_id}")
if gog_count > 0:
self._log(f"GOG games detected: {gog_count}")
self._log("You can now launch Wabbajack from Steam")
# Calculate time taken
import time
time_taken = int(time.time() - self.start_time)
mins, secs = divmod(time_taken, 60)
time_str = f"{mins} minutes, {secs} seconds" if mins else f"{secs} seconds"
# Store data for success dialog (app_id as string to avoid overflow)
self.installation_complete.emit(True, "", self.launch_options, str(app_id), time_str)
except Exception as e:
error_msg = f"Installation failed: {str(e)}"
self._log(f"\nERROR: {error_msg}")
logger.error(f"Wabbajack installation failed: {e}", exc_info=True)
self.installation_complete.emit(False, error_msg, "", "", "")
class WabbajackInstallerScreen(QWidget):
"""Wabbajack installer GUI screen following standard Jackify layout"""
resize_request = Signal(str)
def __init__(self, stacked_widget=None, additional_tasks_index=3, system_info: Optional[SystemInfo] = None):
super().__init__()
self.stacked_widget = stacked_widget
self.additional_tasks_index = additional_tasks_index
self.system_info = system_info or SystemInfo(is_steamdeck=False)
self.debug = DEBUG_BORDERS
self.install_folder = None
self.shortcut_name = "Wabbajack"
self.worker = None
# Get config handler for default paths
from jackify.backend.handlers.config_handler import ConfigHandler
self.config_handler = ConfigHandler()
# Scroll tracking for professional auto-scroll behavior
self._user_manually_scrolled = False
self._was_at_bottom = True
# Initialize progress reporting
self.progress_indicator = OverallProgressIndicator(show_progress_bar=True)
self.progress_indicator.set_status("Ready", 0)
self.file_progress_list = FileProgressList()
self._setup_ui()
def _setup_ui(self):
"""Set up UI following standard Jackify pattern"""
main_layout = QVBoxLayout(self)
main_layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
main_layout.setContentsMargins(50, 50, 50, 0)
main_layout.setSpacing(12)
if self.debug:
self.setStyleSheet("border: 2px solid magenta;")
# Header
self._setup_header(main_layout)
# Upper section: Form (left) + Activity/Process Monitor (right)
self._setup_upper_section(main_layout)
# Status banner with "Show details" toggle
self._setup_status_banner(main_layout)
# Console output (hidden by default)
self._setup_console(main_layout)
# Buttons
self._setup_buttons(main_layout)
def _setup_header(self, layout):
"""Set up header section"""
header_layout = QVBoxLayout()
header_layout.setSpacing(1)
title = QLabel("<b>Install Wabbajack via Proton</b>")
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;")
title.setAlignment(Qt.AlignHCenter)
title.setMaximumHeight(30)
header_layout.addWidget(title)
desc = QLabel(
"Automated Wabbajack.exe Installation and configuration for running via Proton"
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #ccc; margin: 0px; padding: 0px; line-height: 1.2;")
desc.setAlignment(Qt.AlignHCenter)
desc.setMaximumHeight(40)
header_layout.addWidget(desc)
header_widget = QWidget()
header_widget.setLayout(header_layout)
header_widget.setMaximumHeight(75)
layout.addWidget(header_widget)
def _setup_upper_section(self, layout):
"""Set up upper section: Form (left) + Activity/Process Monitor (right)"""
upper_hbox = QHBoxLayout()
upper_hbox.setContentsMargins(0, 0, 0, 0)
upper_hbox.setSpacing(16)
# LEFT: Form and controls
left_vbox = QVBoxLayout()
left_vbox.setAlignment(Qt.AlignTop)
# [Options] header
options_header = QLabel("<b>[Options]</b>")
options_header.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; font-weight: bold;")
options_header.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
left_vbox.addWidget(options_header)
# Form grid
form_grid = QGridLayout()
form_grid.setHorizontalSpacing(12)
form_grid.setVerticalSpacing(6)
form_grid.setContentsMargins(0, 0, 0, 0)
# Shortcut Name
shortcut_name_label = QLabel("Shortcut Name:")
self.shortcut_name_edit = QLineEdit("Wabbajack")
self.shortcut_name_edit.setMaximumHeight(25)
self.shortcut_name_edit.setToolTip("Name for the Steam shortcut (useful if installing multiple Wabbajack instances)")
form_grid.addWidget(shortcut_name_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addWidget(self.shortcut_name_edit, 0, 1)
# Installation Directory
install_dir_label = QLabel("Installation Directory:")
# Set default to $Install_Base_Dir/Wabbajack with actual text (not placeholder)
default_install_dir = Path(self.config_handler.get_modlist_install_base_dir()) / "Wabbajack"
self.install_dir_edit = QLineEdit(str(default_install_dir))
self.install_dir_edit.setMaximumHeight(25)
browse_btn = QPushButton("Browse")
browse_btn.setFixedSize(80, 25)
browse_btn.clicked.connect(self._browse_folder)
install_dir_hbox = QHBoxLayout()
install_dir_hbox.addWidget(self.install_dir_edit)
install_dir_hbox.addWidget(browse_btn)
form_grid.addWidget(install_dir_label, 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addLayout(install_dir_hbox, 1, 1)
form_section_widget = QWidget()
form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
form_section_widget.setLayout(form_grid)
form_section_widget.setMinimumHeight(80)
form_section_widget.setMaximumHeight(120)
left_vbox.addWidget(form_section_widget)
# Info text
info_label = QLabel(
"Enter your preferred name for the Steam shortcut for Wabbajack, then select where Wabbajack should be installed.\n\n"
"Jackify will then download Wabbajack.exe, add it as a new non-Steam game and configure the Proton prefix. "
"The WebView2 installation and prefix configuration will then take place.\n\n"
"While there is initial support for GOG versions, please note that it relies on the game being installed via Heroic Game Launcher. "
"The modlist itself must also support the GOG version of the game."
)
info_label.setStyleSheet("color: #999; font-size: 11px;")
info_label.setWordWrap(True)
left_vbox.addWidget(info_label)
left_widget = QWidget()
left_widget.setLayout(left_vbox)
# RIGHT: Activity/Process Monitor tabs
# No Process Monitor tab - we're not tracking processes
# Just show Activity directly
# Activity heading
activity_heading = QLabel("<b>[Activity]</b>")
activity_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px;")
activity_vbox = QVBoxLayout()
activity_vbox.setContentsMargins(0, 0, 0, 0)
activity_vbox.setSpacing(2)
activity_vbox.addWidget(activity_heading)
activity_vbox.addWidget(self.file_progress_list)
activity_widget = QWidget()
activity_widget.setLayout(activity_vbox)
upper_hbox.addWidget(left_widget, stretch=11)
upper_hbox.addWidget(activity_widget, stretch=9)
upper_section_widget = QWidget()
upper_section_widget.setLayout(upper_hbox)
upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
upper_section_widget.setMaximumHeight(280)
layout.addWidget(upper_section_widget)
def _setup_status_banner(self, layout):
"""Set up status banner with Show details checkbox"""
banner_row = QHBoxLayout()
banner_row.setContentsMargins(0, 0, 0, 0)
banner_row.setSpacing(8)
banner_row.addWidget(self.progress_indicator, 1)
banner_row.addStretch()
self.show_details_checkbox = QCheckBox("Show details")
self.show_details_checkbox.setChecked(False)
self.show_details_checkbox.setToolTip("Toggle detailed console output")
self.show_details_checkbox.toggled.connect(self._on_show_details_toggled)
banner_row.addWidget(self.show_details_checkbox)
banner_row_widget = QWidget()
banner_row_widget.setLayout(banner_row)
banner_row_widget.setMaximumHeight(45)
banner_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
layout.addWidget(banner_row_widget)
def _setup_console(self, layout):
"""Set up console output area (hidden by default)"""
self.console = QTextEdit()
self.console.setReadOnly(True)
self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.console.setMinimumHeight(50)
self.console.setMaximumHeight(1000)
self.console.setFontFamily('monospace')
self.console.setVisible(False)
if self.debug:
self.console.setStyleSheet("border: 2px solid yellow;")
# Set up scroll tracking for professional auto-scroll behavior
self._setup_scroll_tracking()
layout.addWidget(self.console, stretch=1)
def _setup_scroll_tracking(self):
"""Set up scroll tracking for professional auto-scroll behavior"""
scrollbar = self.console.verticalScrollBar()
scrollbar.sliderPressed.connect(self._on_scrollbar_pressed)
scrollbar.sliderReleased.connect(self._on_scrollbar_released)
scrollbar.valueChanged.connect(self._on_scrollbar_value_changed)
def _on_scrollbar_pressed(self):
"""User started manually scrolling"""
self._user_manually_scrolled = True
def _on_scrollbar_released(self):
"""User finished manually scrolling"""
self._user_manually_scrolled = False
def _on_scrollbar_value_changed(self):
"""Track if user is at bottom of scroll area"""
scrollbar = self.console.verticalScrollBar()
self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1
def _setup_buttons(self, layout):
"""Set up action buttons"""
btn_row = QHBoxLayout()
btn_row.setAlignment(Qt.AlignHCenter)
self.start_btn = QPushButton("Start Installation")
self.start_btn.setFixedHeight(35)
# Enable by default since we have a default directory
self.start_btn.setEnabled(True)
self.start_btn.clicked.connect(self._start_installation)
btn_row.addWidget(self.start_btn)
self.cancel_btn = QPushButton("Cancel")
self.cancel_btn.setFixedHeight(35)
self.cancel_btn.clicked.connect(self._go_back)
btn_row.addWidget(self.cancel_btn)
btn_row_widget = QWidget()
btn_row_widget.setLayout(btn_row)
btn_row_widget.setMaximumHeight(50)
layout.addWidget(btn_row_widget)
def _on_show_details_toggled(self, checked):
"""Handle Show details checkbox toggle"""
self.console.setVisible(checked)
if checked:
self.resize_request.emit("expand")
else:
self.resize_request.emit("compact")
def _browse_folder(self):
"""Browse for installation folder"""
folder = QFileDialog.getExistingDirectory(
self,
"Select Wabbajack Installation Folder",
str(Path.home()),
QFileDialog.ShowDirsOnly
)
if folder:
self.install_folder = Path(folder)
self.install_dir_edit.setText(str(self.install_folder))
self.start_btn.setEnabled(True)
# Update shortcut name from field
self.shortcut_name = self.shortcut_name_edit.text().strip() or "Wabbajack"
def _start_installation(self):
"""Start the installation process"""
# Get install folder from text field (may be default or user-selected)
install_dir_text = self.install_dir_edit.text().strip()
if not install_dir_text:
MessageService.warning(self, "No Folder Selected", "Please select an installation folder first.")
return
self.install_folder = Path(install_dir_text)
# Get shortcut name
self.shortcut_name = self.shortcut_name_edit.text().strip() or "Wabbajack"
# Confirm with user
confirm = MessageService.question(
self,
"Confirm Installation",
f"Install Wabbajack to:\n{self.install_folder}\n\n"
"This will download Wabbajack, add to Steam, install WebView2,\n"
"and configure the Wine prefix automatically.\n\n"
"Steam will be restarted during installation.\n\n"
"Continue?"
)
if not confirm:
return
# Clear displays
self.console.clear()
self.file_progress_list.clear()
# Update UI state
self.start_btn.setEnabled(False)
self.cancel_btn.setEnabled(False)
self.progress_indicator.set_status("Starting installation...", 0)
# Start worker thread
self.worker = WabbajackInstallerWorker(self.install_folder, shortcut_name=self.shortcut_name, enable_gog=True)
self.worker.progress_update.connect(self._on_progress_update)
self.worker.activity_update.connect(self._on_activity_update)
self.worker.log_output.connect(self._on_log_output)
self.worker.installation_complete.connect(self._on_installation_complete)
self.worker.start()
def _on_progress_update(self, message: str, percentage: int):
"""Handle progress updates"""
self.progress_indicator.set_status(message, percentage)
def _on_activity_update(self, label: str, current: int, total: int):
"""Handle activity tab updates"""
self.file_progress_list.update_files(
[],
current_phase=label, # Use the actual step label (e.g., "Checking requirements", "Downloading Wabbajack.exe", etc.)
summary_info={"current_step": current, "max_steps": total}
)
def _on_log_output(self, message: str):
"""Handle log output with professional auto-scroll"""
scrollbar = self.console.verticalScrollBar()
was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1)
self.console.append(message)
# Auto-scroll if user was at bottom and hasn't manually scrolled
if (was_at_bottom and not self._user_manually_scrolled) or \
(not self._user_manually_scrolled and scrollbar.value() >= scrollbar.maximum() - 2):
scrollbar.setValue(scrollbar.maximum())
if scrollbar.value() == scrollbar.maximum():
self._was_at_bottom = True
def _on_installation_complete(self, success: bool, message: str, launch_options: str = "", app_id: str = "", time_taken: str = ""):
"""Handle installation completion"""
if success:
self.progress_indicator.set_status("Installation complete!", 100)
# Use SuccessDialog like other screens
from ..dialogs.success_dialog import SuccessDialog
from PySide6.QtWidgets import QLabel, QFrame
success_dialog = SuccessDialog(
modlist_name="Wabbajack",
workflow_type="install",
time_taken=time_taken,
game_name=None,
parent=self
)
# Increase dialog size to accommodate note section (Steam Deck: 1280x800)
# Use wider dialog to reduce vertical space needed (more horizontal space available)
success_dialog.setFixedSize(650, 550) # Wider for Steam Deck (1280px width)
# Add compat mounts note in a separate bordered section
note_text = ""
if launch_options and "STEAM_COMPAT_MOUNTS" in launch_options:
note_text = "<b>Note:</b> To access other drives, add paths to launch options (Steam → Properties). "
note_text += "Append with colons: <code>STEAM_COMPAT_MOUNTS=\"/existing:/new/path\" %command%</code>"
elif not launch_options:
note_text = "<b>Note:</b> To access other drives, add to launch options (Steam → Properties): "
note_text += "<code>STEAM_COMPAT_MOUNTS=\"/path/to/directory\" %command%</code>"
if note_text:
# Find the card widget and add a note section after the next steps
card = success_dialog.findChild(QFrame, "successCard")
if card:
# Remove fixed height constraint and increase minimum (Steam Deck optimized)
card.setFixedWidth(590) # Wider card to match wider dialog
card.setMinimumHeight(380) # Reduced height due to wider text wrapping
card.setMaximumHeight(16777215) # Remove max height constraint
card_layout = card.layout()
if card_layout:
# Create a bordered note frame with proper sizing
note_frame = QFrame()
note_frame.setFrameShape(QFrame.StyledPanel)
note_frame.setStyleSheet(
"QFrame { "
" background: #2a2f36; "
" border: 1px solid #3fb7d6; "
" border-radius: 6px; "
" padding: 10px; "
" margin-top: 6px; "
"}"
)
# Make note frame size naturally based on content (Steam Deck optimized)
note_frame.setMinimumHeight(80)
note_frame.setMaximumHeight(16777215) # No max constraint
note_frame.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding)
note_layout = QVBoxLayout(note_frame)
note_layout.setContentsMargins(10, 10, 10, 10) # Reduced padding
note_layout.setSpacing(0)
note_label = QLabel(note_text)
note_label.setWordWrap(True)
note_label.setTextFormat(Qt.RichText)
note_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
note_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding)
# No minimum height - let it size naturally based on content
note_label.setStyleSheet(
"QLabel { "
" font-size: 11px; "
" color: #b0b0b0; "
" line-height: 1.3; "
"}"
)
note_layout.addWidget(note_label)
# Insert before the Ko-Fi link (which should be near the end)
# Find the index of the Ko-Fi label or add at the end
insert_index = card_layout.count() - 2 # Before buttons, after next steps
card_layout.insertWidget(insert_index, note_frame)
success_dialog.show()
# Reset UI
self.install_folder = None
# Reset to default directory
default_install_dir = Path(self.config_handler.get_modlist_install_base_dir()) / "Wabbajack"
self.install_dir_edit.setText(str(default_install_dir))
self.shortcut_name_edit.setText("Wabbajack")
self.start_btn.setEnabled(True) # Re-enable since we have default directory
self.cancel_btn.setEnabled(True)
else:
self.progress_indicator.set_status("Installation failed", 0)
MessageService.critical(self, "Installation Failed", message)
self.start_btn.setEnabled(True)
self.cancel_btn.setEnabled(True)
def _go_back(self):
"""Return to Additional Tasks menu"""
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.additional_tasks_index)
def showEvent(self, event):
"""Called when widget becomes visible"""
super().showEvent(event)
try:
main_window = self.window()
if main_window:
from PySide6.QtCore import QSize
main_window.setMaximumSize(QSize(16777215, 16777215))
set_responsive_minimum(main_window, min_width=960, min_height=420)
except Exception:
pass

View File

@@ -149,14 +149,6 @@ class FileProgressItem(QWidget):
layout.addWidget(percent_label)
self.percent_label = percent_label
# Speed display (if available)
speed_label = QLabel()
speed_label.setFixedWidth(60)
speed_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
speed_label.setStyleSheet("color: #888; font-size: 10px;")
layout.addWidget(speed_label)
self.speed_label = speed_label
# Progress indicator: either progress bar (with %) or animated spinner (no %)
progress_bar = QProgressBar()
progress_bar.setFixedHeight(12)
@@ -223,7 +215,6 @@ class FileProgressItem(QWidget):
if no_progress_bar:
self._animation_timer.stop() # Stop animation for items without progress bars
self.percent_label.setText("") # No percentage
self.speed_label.setText("") # No speed
self.progress_bar.setVisible(False) # Hide progress bar
return
@@ -244,14 +235,12 @@ class FileProgressItem(QWidget):
if not self._animation_timer.isActive():
self._animation_timer.start()
self.speed_label.setText("") # No speed for summary
self.progress_bar.setRange(0, 100)
# Progress bar value will be updated by animation timer
else:
# No max for summary - use custom animated spinner
self._is_indeterminate = True
self.percent_label.setText("")
self.speed_label.setText("")
self.progress_bar.setRange(0, 100) # Use determinate range for custom animation
if not self._animation_timer.isActive():
self._animation_timer.start()
@@ -271,7 +260,6 @@ class FileProgressItem(QWidget):
self._is_indeterminate = False
self._animation_timer.stop()
self.percent_label.setText("Queued")
self.speed_label.setText("")
self.progress_bar.setRange(0, 100)
self.progress_bar.setValue(0)
return
@@ -295,15 +283,12 @@ class FileProgressItem(QWidget):
if not self._animation_timer.isActive():
self._animation_timer.start()
# Update speed label immediately (doesn't need animation)
self.speed_label.setText(self.file_progress.speed_display)
self.progress_bar.setRange(0, 100)
# Progress bar value will be updated by animation timer
else:
# No progress data (e.g., texture conversions, BSA building) - use custom animated spinner
self._is_indeterminate = True
self.percent_label.setText("") # Clear percent label
self.speed_label.setText("") # No speed
self.progress_bar.setRange(0, 100) # Use determinate range for custom animation
# Start animation timer for custom spinner
if not self._animation_timer.isActive():

BIN
jackify/tools/7z Executable file

Binary file not shown.

BIN
jackify/tools/sha256sum Executable file

Binary file not shown.

BIN
jackify/tools/unzip Executable file

Binary file not shown.

BIN
jackify/tools/wget Executable file

Binary file not shown.