mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-01-17 11:37:01 +01:00
Sync from development - prepare for v0.2.1
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
317
jackify/backend/handlers/enb_handler.py
Normal file
317
jackify/backend/handlers/enb_handler.py
Normal 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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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='')
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
601
jackify/backend/handlers/wabbajack_installer_handler.py
Normal file
601
jackify/backend/handlers/wabbajack_installer_handler.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
185
jackify/frontends/gui/dialogs/enb_proton_dialog.py
Normal file
185
jackify/frontends/gui/dialogs/enb_proton_dialog.py
Normal 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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
714
jackify/frontends/gui/screens/wabbajack_installer.py
Normal file
714
jackify/frontends/gui/screens/wabbajack_installer.py
Normal 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
|
||||
@@ -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
BIN
jackify/tools/7z
Executable file
Binary file not shown.
BIN
jackify/tools/sha256sum
Executable file
BIN
jackify/tools/sha256sum
Executable file
Binary file not shown.
BIN
jackify/tools/unzip
Executable file
BIN
jackify/tools/unzip
Executable file
Binary file not shown.
BIN
jackify/tools/wget
Executable file
BIN
jackify/tools/wget
Executable file
Binary file not shown.
Reference in New Issue
Block a user