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

@@ -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