6 Commits

Author SHA1 Message Date
Omni
29e1800074 Sync from development - prepare for v0.2.1 2026-01-12 22:15:19 +00:00
Omni
9b5310c2f9 Sync from development - prepare for v0.2.0.10 2026-01-04 22:43:32 +00:00
Omni
0d84d2f2fe Sync from development - prepare for v0.2.0.9 2025-12-31 20:56:47 +00:00
Omni
2511c9334c Sync from development - prepare for v0.2.0.8 2025-12-29 19:55:38 +00:00
Omni
5869a896a8 Sync from development - prepare for v0.2.0.7 2025-12-28 22:17:44 +00:00
Omni
99fb369d5e Sync from development - prepare for v0.2.0.6 2025-12-28 18:52:07 +00:00
84 changed files with 4259 additions and 843 deletions

View File

@@ -1,5 +1,111 @@
# Jackify Changelog
## v0.2.1 - Wabbajack Installer and ENB Support
**Release Date:** 2025-01-12
### Major Features
- **Automated Wabbajack Installation**: While I work on Non-Premium support, there is still a call for Wabbajack via Proton. The existing legacy bash script has been proving troublesome for some users, so I've added this as a new feature within Jackify. My aim is still to not need this in future, once Jackify can cover Non-Premium accounts.
- **ENB Detection and Configuration**: Automatic detection and configuration of `enblocal.ini` with `LinuxVersion=true` for all supported games
- **ENB Proton Warning**: Dedicated dialog with Proton version recommendations when ENB is detected
### Critical Bug Fixes
- **OAuth Token Stale State**: Re-check authentication before engine launch to prevent stale token errors after revocation
- **FNV SD Card Registry**: Fixed launcher not recognizing game on SD cards (uses `D:` drive for SD, `Z:` for internal)
- **CLI FILE_PROGRESS Spam**: Filter verbose output to preserve single-line progress updates
- **Steam Double Restart**: Removed legacy code causing double restart during configuration
- **TTW Installer lz4**: Fixed bundled lz4 detection by setting correct working directory
### Improvements
- **Winetricks Bundling**: Bundled critical dependencies (wget, sha256sum, unzip, 7z) for improved reliability
- **UI/UX**: Removed per-file download speeds to match Wabbajack upstream
- **Code Cleanup**: Removed PyInstaller references, use AppImage detection only
- **Wabbajack Installer UI**: Removed unused Process Monitor tab, improved Activity window with detailed step information
- **Steam AppID Overflow Fix**: Changed AppID handling to string type to prevent overflow errors with large Steam AppIDs
---
## v0.2.0.10 - Registry & Hashing Fixes
**Release Date:** 2025-01-04
### Engine Updates
- **jackify-engine 0.4.5**: Fixed archive extraction with backslashes (including pattern matching), data directory path configuration, and removed post-download .wabbajack hash validation. Engine now auto-refreshes OAuth tokens during long installations via `NEXUS_OAUTH_INFO` environment variable.
### Critical Bug Fixes
- **InstallationThread Crash**: Fixed crash during installation with error "'InstallationThread' object has no attribute 'auth_service'". Premium detection diagnostics code assumed auth_service existed but it was never passed to the thread. Affects all users when Premium detection (including false positives) is triggered.
- **Install Start Hang**: Fixed missing `oauth_info` parameter that prevented modlist installs from starting (hung at "Starting modlist installation...")
- **OAuth Token Auto-Refresh**: Fixed OAuth tokens expiring during long modlist installations. Jackify now refreshes tokens with 15-minute buffer before passing to engine. Engine receives full OAuth state via `NEXUS_OAUTH_INFO` environment variable, enabling automatic token refresh during multi-hour downloads. Fixes "Token has expired" errors that occurred 60 minutes into installations.
- **ShowDotFiles Registry Format**: Fixed Wine registry format bug causing hidden files to remain hidden in prefixes. Python string escaping issue wrote single backslash instead of double backslash in `[Software\\Wine]` section header. Added auto-detection and fix for broken format from curated registry files.
- **Dotnet4 Registry Fixes**: Confirmed universal dotnet4.x registry fixes (`*mscoree=native` and `OnlyUseLatestCLR=1`) are applied in all three workflows (Install, Configure New, Configure Existing) across both CLI and GUI interfaces
- **Proton Path Configuration**: Fixed `proton_path` writing invalid "auto" string to config.json - now uses `null` instead, preventing jackify-engine from receiving invalid paths
### Improvements
- **Wine Binary Detection**: Enhanced detection with recursive fallback search within Proton directory when expected paths don't exist (handles different Proton version structures)
- Added Jackify version logging at workflow start
- Fixed GUI log file rotation to only run in debug mode
---
## v0.2.0.9 - Critical Configuration Fixes
**Release Date:** 2025-12-31
### Bug Fixes
- Fixed AppID conversion bug causing Configure Existing failures
- Fixed missing MessageService import crash in Configure Existing
- Fixed RecursionError in config_handler.py logger
- Fixed winetricks automatic fallback to protontricks (was silently failing)
### Improvements
- Added detailed progress indicators for configuration workflows
- Fixed progress bar completion showing 100% instead of 95%
- Removed debug logging noise from file progress widget
- Enhanced Premium detection diagnostics for Issue #111
- Flatpak protontricks now auto-granted cache access for faster subsequent installs
---
## v0.2.0.8 - Bug Fixes and Improvements
**Release Date:** 2025-12-29
### Bug Fixes
- Fixed Configure New/Existing/TTW screens missing Activity tab and progress updates
- Fixed cancel/back buttons crashing in Configure workflows
### Improvements
- Install directory now auto-appends modlist name when selected from gallery
### Known Issues
- Mod filter temporarily disabled in gallery due to technical issue (tag and game filters still work)
---
## v0.2.0.7 - Critical Auth Fix
**Release Date:** 2025-12-28
### Critical Bug Fixes
- **OAuth Token Loss**: Fixed version comparison bug that was deleting OAuth tokens every time settings were saved (affects users on v0.2.0.4+)
- Fixed internal import paths for improved stability
---
## v0.2.0.6 - Premium Detection and Engine Update
**Release Date:** 2025-12-28
**IMPORTANT:** If you are on v0.2.0.5, automatic updates will not work. You must manually download and install v0.2.0.6.
### Engine Updates
- **jackify-engine 0.4.4**: Latest engine version with improvements
### Critical Bug Fixes
- **Auto-Update System**: Fixed broken update dialog import that prevented automatic updates
- **Premium Detection**: Fixed false Premium errors caused by overly-broad detection pattern triggering on jackify-engine 0.4.3's userinfo JSON output
- **Custom Data Directory**: Fixed AppImage always creating ~/Jackify on startup, even when user configured a custom jackify_data_dir
- **Proton Auto-Selection**: Fixed auto-selection writing invalid "auto" string to config on detection failure
### Quality Improvements
- Added pre-build import validator to prevent broken imports from reaching production
---
## v0.2.0.5 - Emergency OAuth Fix
**Release Date:** 2025-12-24

View File

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

View File

@@ -34,7 +34,7 @@ def _get_user_proton_version():
# get_proton_path() returns the Install Proton path
user_proton_path = config_handler.get_proton_path()
if user_proton_path == 'auto':
if not user_proton_path or user_proton_path == 'auto':
# Use enhanced fallback logic with GE-Proton preference
logging.info("User selected auto-detect, using GE-Proton → Experimental → Proton precedence")
return WineUtils.select_best_proton()
@@ -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.")
@@ -502,10 +502,11 @@ class ModlistInstallCLI:
print("\n" + "-" * 28)
print(f"{COLOR_INFO}Nexus Authentication: Using API Key (Legacy){COLOR_RESET}")
# Get valid token/key
api_key = auth_service.ensure_valid_auth()
# Get valid token/key and OAuth state for engine auto-refresh
api_key, oauth_info = auth_service.get_auth_for_engine()
if api_key:
self.context['nexus_api_key'] = api_key
self.context['nexus_oauth_info'] = oauth_info # For engine auto-refresh
else:
# Auth expired or invalid - prompt to set up
print(f"\n{COLOR_WARNING}Your authentication has expired or is invalid.{COLOR_RESET}")
@@ -538,9 +539,10 @@ class ModlistInstallCLI:
if username:
print(f"{COLOR_INFO}Authorized as: {username}{COLOR_RESET}")
api_key = auth_service.ensure_valid_auth()
api_key, oauth_info = auth_service.get_auth_for_engine()
if api_key:
self.context['nexus_api_key'] = api_key
self.context['nexus_oauth_info'] = oauth_info # For engine auto-refresh
else:
print(f"{COLOR_ERROR}Failed to retrieve auth token after authorization.{COLOR_RESET}")
return None
@@ -737,7 +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')
# 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()
@@ -779,24 +791,43 @@ class ModlistInstallCLI:
# Store original environment values to restore later
original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
}
try:
# Temporarily modify current process's environment
if api_key:
# Prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY (legacy)
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
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
elif api_key:
# No OAuth info, use API key only (no auto-refresh support)
os.environ['NEXUS_API_KEY'] = api_key
self.logger.debug(f"Temporarily set os.environ['NEXUS_API_KEY'] for engine call using session-provided key.")
elif 'NEXUS_API_KEY' in os.environ: # api_key is None/empty, but a system key might exist
self.logger.debug(f"Session API key not provided. Temporarily removing inherited NEXUS_API_KEY ('{'[REDACTED]' if os.environ.get('NEXUS_API_KEY') else 'None'}') from os.environ for engine call to ensure it is not used.")
del os.environ['NEXUS_API_KEY']
# If api_key is None and NEXUS_API_KEY was not in os.environ, it remains unset, which is correct.
self.logger.debug(f"Set NEXUS_API_KEY for engine (no auto-refresh)")
else:
# No auth available, clear any inherited values
if 'NEXUS_API_KEY' in os.environ:
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"
self.logger.debug(f"Temporarily set os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = '1' for engine call.")
self.logger.info("Environment prepared for jackify-engine install process by modifying os.environ.")
self.logger.debug(f"NEXUS_API_KEY in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_API_KEY') else '[NOT SET]'}")
self.logger.debug(f"NEXUS_OAUTH_INFO in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_OAUTH_INFO') else '[NOT SET]'}")
pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd])
print(f"{COLOR_INFO}Launching Jackify Install Engine with command:{COLOR_RESET} {pretty_cmd}")
@@ -828,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''
@@ -840,7 +889,16 @@ class ModlistInstallCLI:
# Print any remaining buffer content
if buffer:
line = buffer.decode('utf-8', errors='replace')
print(line, end='')
# Filter FILE_PROGRESS spam but keep the status line before it
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
# Skip this line entirely if it's only FILE_PROGRESS
line = ''
if line:
print(line, end='')
proc.wait()
# Clear process reference after completion

View File

@@ -6,6 +6,7 @@ Handles application settings and configuration
"""
import os
import sys
import json
import logging
import shutil
@@ -57,6 +58,8 @@ class ConfigHandler:
"use_winetricks_for_components": True, # DEPRECATED: Migrated to component_installation_method. Kept for backward compatibility.
"component_installation_method": "winetricks", # "winetricks" (default) or "system_protontricks"
"game_proton_path": None, # Proton version for game shortcuts (can be any Proton 9+), separate from install proton
"proton_path": None, # Install Proton path (for jackify-engine) - None means auto-detect
"proton_version": None, # Install Proton version name - None means auto-detect
"steam_restart_strategy": "jackify", # "jackify" (default) or "nak_simple"
"window_width": None, # Saved window width (None = use dynamic sizing)
"window_height": None # Saved window height (None = use dynamic sizing)
@@ -157,7 +160,8 @@ class ConfigHandler:
# Migration: v0.0.x -> v0.2.0
# Encryption changed from cryptography (Fernet) to pycryptodome (AES-GCM)
# Old encrypted API keys cannot be decrypted, must be re-entered
if current_version < "0.2.0":
from packaging import version
if version.parse(current_version) < version.parse("0.2.0"):
# Clear old encrypted credentials
if self.settings.get("nexus_api_key"):
logger.warning("Clearing saved API key due to encryption format change")
@@ -210,7 +214,8 @@ class ConfigHandler:
config.update(saved_config)
return config
except Exception as e:
logger.error(f"Error reading configuration from disk: {e}")
# Don't use logger here - can cause recursion if logger tries to access config
print(f"Warning: Error reading configuration from disk: {e}", file=sys.stderr)
return self.settings.copy()
def reload_config(self):
@@ -754,16 +759,20 @@ class ConfigHandler:
Always reads fresh from disk.
Returns:
str: Saved Install Proton path or 'auto' if not saved
str: Saved Install Proton path, or None if not set (indicates auto-detect mode)
"""
try:
config = self._read_config_from_disk()
proton_path = config.get("proton_path", "auto")
proton_path = config.get("proton_path")
# Return None if missing/None/empty string - don't default to "auto"
if not proton_path:
logger.debug("proton_path not set in config - will use auto-detection")
return None
logger.debug(f"Retrieved fresh install proton_path from config: {proton_path}")
return proton_path
except Exception as e:
logger.error(f"Error retrieving install proton_path: {e}")
return "auto"
return None
def get_game_proton_path(self):
"""
@@ -772,7 +781,7 @@ class ConfigHandler:
Always reads fresh from disk.
Returns:
str: Saved Game Proton path, Install Proton path, or 'auto' if not saved
str: Saved Game Proton path, Install Proton path, or None if not saved (indicates auto-detect mode)
"""
try:
config = self._read_config_from_disk()
@@ -780,8 +789,12 @@ class ConfigHandler:
# If game proton not set or set to same_as_install, use install proton
if not game_proton_path or game_proton_path == "same_as_install":
game_proton_path = config.get("proton_path", "auto")
game_proton_path = config.get("proton_path") # Returns None if not set
# Return None if missing/None/empty string
if not game_proton_path:
logger.debug("game_proton_path not set in config - will use auto-detection")
return None
logger.debug(f"Retrieved fresh game proton_path from config: {game_proton_path}")
return game_proton_path
except Exception as e:
@@ -818,15 +831,20 @@ class ConfigHandler:
logger.info(f"Auto-detected Proton: {best_proton['name']} ({proton_type})")
self.save_config()
else:
# Fallback to auto-detect mode
self.settings["proton_path"] = "auto"
self.settings["proton_version"] = "auto"
logger.info("No compatible Proton versions found, using auto-detect mode")
# Set proton_path to None (will appear as null in JSON) so jackify-engine doesn't get invalid path
# Code will auto-detect on each run when proton_path is None
self.settings["proton_path"] = None
self.settings["proton_version"] = None
logger.warning("No compatible Proton versions found - proton_path set to null in config.json")
logger.info("Jackify will auto-detect Proton on each run until a valid version is found")
self.save_config()
except Exception as e:
logger.error(f"Failed to auto-detect Proton: {e}")
self.settings["proton_path"] = "auto"
self.settings["proton_version"] = "auto"
# Set proton_path to None (will appear as null in JSON)
self.settings["proton_path"] = None
self.settings["proton_version"] = None
logger.warning("proton_path set to null in config.json due to auto-detection failure")
self.save_config()

View File

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

View File

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

View File

@@ -571,15 +571,19 @@ class ModlistMenuHandler:
self.logger.warning(f"[DEBUG] Could not find AppID for {context['name']} with exe {context['mo2_exe_path']}")
set_modlist_result = self.modlist_handler.set_modlist(context)
self.logger.debug(f"[DEBUG] set_modlist returned: {set_modlist_result}")
# Check GUI mode early to avoid input() calls in GUI context
import os
gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
if not set_modlist_result:
print(f"{COLOR_ERROR}\nError setting up context for configuration.{COLOR_RESET}")
self.logger.error(f"set_modlist failed for {context.get('name')}")
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
if not gui_mode:
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
return False
# --- Resolution selection logic for GUI mode ---
import os
gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
selected_resolution = context.get('resolution', None)
if gui_mode:
# If resolution is provided, set it and do not prompt
@@ -640,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
@@ -651,10 +678,26 @@ 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("")
print("Detailed log available at: ~/Jackify/logs/Configure_New_Modlist_workflow.log")
# 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
if not gui_mode:
input(f"{COLOR_PROMPT}Press Enter to return to the menu...{COLOR_RESET}")

View File

@@ -105,9 +105,9 @@ class ModlistHandler:
verbose: Boolean indicating if verbose output is desired.
filesystem_handler: Optional FileSystemHandler instance to use instead of creating a new one.
"""
# Use standard logging (no file handler)
# Use standard logging (propagate to root logger so messages appear in logs)
self.logger = logging.getLogger(__name__)
self.logger.propagate = False
self.logger.propagate = True
self.steamdeck = steamdeck
# DEBUG: Log ModlistHandler instantiation details for SD card path debugging
@@ -746,15 +746,20 @@ class ModlistHandler:
try:
registry_success = self._apply_universal_dotnet_fixes()
except Exception as e:
self.logger.error(f"CRITICAL: Registry fixes failed - modlist may have .NET compatibility issues: {e}")
error_msg = f"CRITICAL: Registry fixes failed - modlist may have .NET compatibility issues: {e}"
self.logger.error(error_msg)
if status_callback:
status_callback(f"{self._get_progress_timestamp()} ERROR: {error_msg}")
registry_success = False
if not registry_success:
failure_msg = "WARNING: Universal dotnet4.x registry fixes FAILED! This modlist may experience .NET Framework compatibility issues."
self.logger.error("=" * 80)
self.logger.error("WARNING: Universal dotnet4.x registry fixes FAILED!")
self.logger.error("This modlist may experience .NET Framework compatibility issues.")
self.logger.error(failure_msg)
self.logger.error("Consider manually setting mscoree=native in winecfg if problems occur.")
self.logger.error("=" * 80)
if status_callback:
status_callback(f"{self._get_progress_timestamp()} {failure_msg}")
# Continue but user should be aware of potential issues
# Step 4.6: Enable dotfiles visibility for Wine prefix
@@ -770,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")
@@ -1596,20 +1632,21 @@ class ModlistHandler:
except Exception as e:
self.logger.warning(f"Wineserver shutdown failed (non-critical): {e}")
# Registry fix 1: Set mscoree=native DLL override
# Registry fix 1: Set *mscoree=native DLL override (asterisk for full override)
# This tells Wine to use native .NET runtime instead of Wine's implementation
self.logger.debug("Setting mscoree=native DLL override...")
self.logger.debug("Setting *mscoree=native DLL override...")
cmd1 = [
wine_binary, 'reg', 'add',
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
'/v', 'mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
'/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
]
result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30)
self.logger.info(f"*mscoree registry command result: returncode={result1.returncode}, stdout={result1.stdout[:200]}, stderr={result1.stderr[:200]}")
if result1.returncode == 0:
self.logger.info("Successfully applied mscoree=native DLL override")
self.logger.info("Successfully applied *mscoree=native DLL override")
else:
self.logger.warning(f"Failed to set mscoree DLL override: {result1.stderr}")
self.logger.error(f"Failed to set *mscoree DLL override: returncode={result1.returncode}, stderr={result1.stderr}")
# Registry fix 2: Set OnlyUseLatestCLR=1
# This prevents .NET version conflicts by using the latest CLR
@@ -1621,10 +1658,11 @@ class ModlistHandler:
]
result2 = subprocess.run(cmd2, env=env, capture_output=True, text=True, errors='replace', timeout=30)
self.logger.info(f"OnlyUseLatestCLR registry command result: returncode={result2.returncode}, stdout={result2.stdout[:200]}, stderr={result2.stderr[:200]}")
if result2.returncode == 0:
self.logger.info("Successfully applied OnlyUseLatestCLR=1 registry entry")
else:
self.logger.warning(f"Failed to set OnlyUseLatestCLR: {result2.stderr}")
self.logger.error(f"Failed to set OnlyUseLatestCLR: returncode={result2.returncode}, stderr={result2.stderr}")
# Force wineserver to flush registry changes to disk
if wineserver_binary:
@@ -1639,17 +1677,17 @@ class ModlistHandler:
self.logger.info("Verifying registry entries were applied and persisted...")
verification_passed = True
# Verify mscoree=native
# Verify *mscoree=native
verify_cmd1 = [
wine_binary, 'reg', 'query',
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
'/v', 'mscoree'
'/v', '*mscoree'
]
verify_result1 = subprocess.run(verify_cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30)
if verify_result1.returncode == 0 and 'native' in verify_result1.stdout:
self.logger.info("VERIFIED: mscoree=native is set correctly")
self.logger.info("VERIFIED: *mscoree=native is set correctly")
else:
self.logger.error(f"VERIFICATION FAILED: mscoree=native not found in registry. Query output: {verify_result1.stdout}")
self.logger.error(f"VERIFICATION FAILED: *mscoree=native not found in registry. Query output: {verify_result1.stdout}")
verification_passed = False
# Verify OnlyUseLatestCLR=1
@@ -1678,45 +1716,75 @@ 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():
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)
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
def _search_wine_in_proton_directory(self, proton_path: Path) -> Optional[str]:
"""
Recursively search for wine binary within a Proton directory.
This handles cases where the directory structure might differ between Proton versions.
Args:
proton_path: Path to the Proton directory to search
Returns:
Path to wine binary if found, None otherwise
"""
try:
if not proton_path.exists() or not proton_path.is_dir():
return None
# Search for 'wine' executable (not 'wine64' or 'wine-preloader')
# Limit search depth to avoid scanning entire filesystem
max_depth = 5
for root, dirs, files in os.walk(proton_path, followlinks=False):
# Calculate depth relative to proton_path
depth = len(Path(root).relative_to(proton_path).parts)
if depth > max_depth:
dirs.clear() # Don't descend further
continue
# Check if 'wine' is in this directory
if 'wine' in files:
wine_path = Path(root) / 'wine'
# Verify it's actually an executable file
if wine_path.is_file() and os.access(wine_path, os.X_OK):
self.logger.debug(f"Found wine binary at: {wine_path}")
return str(wine_path)
return None
except Exception as e:
self.logger.debug(f"Error during recursive wine search in {proton_path}: {e}")
return None

View File

@@ -48,10 +48,11 @@ logger = logging.getLogger(__name__) # Standard logger init
# Helper function to get path to jackify-install-engine
def get_jackify_engine_path():
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
# Running inside the bundled AppImage (frozen)
# Engine is expected at <bundle_root>/jackify/engine/jackify-engine
return os.path.join(sys._MEIPASS, 'jackify', 'engine', 'jackify-engine')
appdir = os.environ.get('APPDIR')
if appdir:
# Running inside AppImage
# Engine is expected at <appdir>/opt/jackify/engine/jackify-engine
return os.path.join(appdir, 'opt', 'jackify', 'engine', 'jackify-engine')
else:
# Running in a normal Python environment from source
# Current file is in src/jackify/backend/handlers/modlist_install_cli.py
@@ -427,10 +428,11 @@ class ModlistInstallCLI:
print("\n" + "-" * 28)
print(f"{COLOR_INFO}Nexus Authentication: Using API Key (Legacy){COLOR_RESET}")
# Get valid token/key
api_key = auth_service.ensure_valid_auth()
# Get valid token/key and OAuth state for engine auto-refresh
api_key, oauth_info = auth_service.get_auth_for_engine()
if api_key:
self.context['nexus_api_key'] = api_key
self.context['nexus_oauth_info'] = oauth_info # For engine auto-refresh
else:
# Auth expired or invalid - prompt to set up
print(f"\n{COLOR_WARNING}Your authentication has expired or is invalid.{COLOR_RESET}")
@@ -463,9 +465,10 @@ class ModlistInstallCLI:
if username:
print(f"{COLOR_INFO}Authorized as: {username}{COLOR_RESET}")
api_key = auth_service.ensure_valid_auth()
api_key, oauth_info = auth_service.get_auth_for_engine()
if api_key:
self.context['nexus_api_key'] = api_key
self.context['nexus_oauth_info'] = oauth_info # For engine auto-refresh
else:
print(f"{COLOR_ERROR}Failed to retrieve auth token after authorization.{COLOR_RESET}")
return None
@@ -615,7 +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']
# 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()
@@ -675,24 +688,43 @@ class ModlistInstallCLI:
# Store original environment values to restore later
original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
}
try:
# Temporarily modify current process's environment
if api_key:
# Prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY (legacy)
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
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
elif api_key:
# No OAuth info, use API key only (no auto-refresh support)
os.environ['NEXUS_API_KEY'] = api_key
self.logger.debug(f"Temporarily set os.environ['NEXUS_API_KEY'] for engine call using session-provided key.")
elif 'NEXUS_API_KEY' in os.environ: # api_key is None/empty, but a system key might exist
self.logger.debug(f"Session API key not provided. Temporarily removing inherited NEXUS_API_KEY ('{'[REDACTED]' if os.environ.get('NEXUS_API_KEY') else 'None'}') from os.environ for engine call to ensure it is not used.")
del os.environ['NEXUS_API_KEY']
# If api_key is None and NEXUS_API_KEY was not in os.environ, it remains unset, which is correct.
self.logger.debug(f"Set NEXUS_API_KEY for engine (no auto-refresh)")
else:
# No auth available, clear any inherited values
if 'NEXUS_API_KEY' in os.environ:
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"
self.logger.debug(f"Temporarily set os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = '1' for engine call.")
self.logger.info("Environment prepared for jackify-engine install process by modifying os.environ.")
self.logger.debug(f"NEXUS_API_KEY in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_API_KEY') else '[NOT SET]'}")
self.logger.debug(f"NEXUS_OAUTH_INFO in os.environ (pre-call): {'[SET]' if os.environ.get('NEXUS_OAUTH_INFO') else '[NOT SET]'}")
pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd])
print(f"{COLOR_INFO}Launching Jackify Install Engine with command:{COLOR_RESET} {pretty_cmd}")
@@ -764,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='')
@@ -772,6 +814,16 @@ class ModlistInstallCLI:
elif chunk == b'\r':
# Carriage return - decode and print without newline
line = buffer.decode('utf-8', errors='replace')
# Filter FILE_PROGRESS spam but keep the status line before it
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
# Skip this line entirely if it's only FILE_PROGRESS
buffer = b''
last_progress_time = time.time()
continue
# Enhance Nexus download errors with modlist context
enhanced_line = self._enhance_nexus_error(line)
print(enhanced_line, end='')

View File

@@ -142,6 +142,11 @@ class OAuthTokenHandler:
"""
try:
from Crypto.Cipher import AES
# Check if MODE_GCM is available (pycryptodome has it, old pycrypto doesn't)
if not hasattr(AES, 'MODE_GCM'):
logger.error("pycryptodome required for token decryption (pycrypto doesn't support MODE_GCM)")
return None
# Derive 32-byte AES key from encryption_key
key = base64.urlsafe_b64decode(self._encryption_key)
@@ -163,6 +168,9 @@ class OAuthTokenHandler:
except ImportError:
logger.error("pycryptodome package not available for token decryption")
return None
except AttributeError:
logger.error("pycryptodome required for token decryption (pycrypto doesn't support MODE_GCM)")
return None
except Exception as e:
logger.error(f"Failed to decrypt data: {e}")
return None

View File

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

View File

@@ -28,7 +28,8 @@ class ProtontricksHandler:
def __init__(self, steamdeck: bool, logger=None):
self.logger = logger or logging.getLogger(__name__)
self.which_protontricks = None # 'flatpak' or 'native'
self.which_protontricks = None # 'flatpak', 'native', or 'bundled'
self.flatpak_install_type = None # 'user' or 'system' (for flatpak installations)
self.protontricks_version = None
self.protontricks_path = None
self.steamdeck = steamdeck # Store steamdeck status
@@ -209,19 +210,36 @@ class ProtontricksHandler:
except Exception as e:
logger.error(f"Error reading protontricks executable: {e}")
# Check if flatpak protontricks is installed
# Check if flatpak protontricks is installed (check both user and system)
try:
env = self._get_clean_subprocess_env()
result = subprocess.run(
["flatpak", "list"],
# Check user installation first
result_user = subprocess.run(
["flatpak", "list", "--user"],
capture_output=True,
text=True,
env=env
)
if result.returncode == 0 and "com.github.Matoking.protontricks" in result.stdout:
logger.info("Flatpak Protontricks is installed")
if result_user.returncode == 0 and "com.github.Matoking.protontricks" in result_user.stdout:
logger.info("Flatpak Protontricks is installed (user-level)")
self.which_protontricks = 'flatpak'
self.flatpak_install_type = 'user'
return True
# Check system installation
result_system = subprocess.run(
["flatpak", "list", "--system"],
capture_output=True,
text=True,
env=env
)
if result_system.returncode == 0 and "com.github.Matoking.protontricks" in result_system.stdout:
logger.info("Flatpak Protontricks is installed (system-level)")
self.which_protontricks = 'flatpak'
self.flatpak_install_type = 'system'
return True
except FileNotFoundError:
logger.warning("'flatpak' command not found. Cannot check for Flatpak Protontricks.")
except Exception as e:
@@ -230,7 +248,46 @@ class ProtontricksHandler:
# Not found
logger.warning("Protontricks not found (native or flatpak).")
return False
def _get_flatpak_run_args(self) -> List[str]:
"""
Get the correct flatpak run arguments based on installation type.
Returns list starting with ['flatpak', 'run', '--user'|'--system', ...]
"""
base_args = ["flatpak", "run"]
if self.flatpak_install_type == 'user':
base_args.append("--user")
elif self.flatpak_install_type == 'system':
base_args.append("--system")
# If flatpak_install_type is None, don't add flag (shouldn't happen in normal flow)
return base_args
def _get_flatpak_alias_string(self, command=None) -> str:
"""
Get the correct flatpak alias string based on installation type.
Args:
command: Optional command override (e.g., 'protontricks-launch').
If None, returns base protontricks alias.
Returns:
String like 'flatpak run --user com.github.Matoking.protontricks'
"""
flag = f"--{self.flatpak_install_type}" if self.flatpak_install_type else ""
if command:
# For commands like protontricks-launch
if flag:
return f"flatpak run {flag} --command={command} com.github.Matoking.protontricks"
else:
return f"flatpak run --command={command} com.github.Matoking.protontricks"
else:
# Base protontricks command
if flag:
return f"flatpak run {flag} com.github.Matoking.protontricks"
else:
return f"flatpak run com.github.Matoking.protontricks"
def check_protontricks_version(self):
"""
Check if the protontricks version is sufficient
@@ -238,7 +295,7 @@ class ProtontricksHandler:
"""
try:
if self.which_protontricks == 'flatpak':
cmd = ["flatpak", "run", "com.github.Matoking.protontricks", "-V"]
cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks", "-V"]
else:
cmd = ["protontricks", "-V"]
@@ -296,7 +353,7 @@ class ProtontricksHandler:
cmd = [python_exe, "-m", "protontricks.cli.main"]
cmd.extend([str(a) for a in args])
elif self.which_protontricks == 'flatpak':
cmd = ["flatpak", "run", "com.github.Matoking.protontricks"]
cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks"]
cmd.extend(args)
else: # native
cmd = ["protontricks"]
@@ -443,15 +500,17 @@ class ProtontricksHandler:
protontricks_alias_exists = "alias protontricks=" in content
launch_alias_exists = "alias protontricks-launch" in content
# Add missing aliases
# Add missing aliases with correct flag based on installation type
with open(bashrc_path, 'a') as f:
if not protontricks_alias_exists:
logger.info("Adding protontricks alias to ~/.bashrc")
f.write("\nalias protontricks='flatpak run com.github.Matoking.protontricks'\n")
alias_cmd = self._get_flatpak_alias_string()
f.write(f"\nalias protontricks='{alias_cmd}'\n")
if not launch_alias_exists:
logger.info("Adding protontricks-launch alias to ~/.bashrc")
f.write("\nalias protontricks-launch='flatpak run --command=protontricks-launch com.github.Matoking.protontricks'\n")
launch_alias_cmd = self._get_flatpak_alias_string(command='protontricks-launch')
f.write(f"\nalias protontricks-launch='{launch_alias_cmd}'\n")
return True
else:
@@ -500,7 +559,7 @@ class ProtontricksHandler:
try:
cmd = [] # Initialize cmd list
if self.which_protontricks == 'flatpak':
cmd = ["flatpak", "run", "com.github.Matoking.protontricks", "-l"]
cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks", "-l"]
elif self.protontricks_path:
cmd = [self.protontricks_path, "-l"]
else:
@@ -596,19 +655,29 @@ class ProtontricksHandler:
try:
if user_reg_path.exists():
content = user_reg_path.read_text(encoding='utf-8', errors='ignore')
if "ShowDotFiles" not in content:
# Check for CORRECT format with proper backslash escaping
has_correct_format = '[Software\\\\Wine]' in content and '"ShowDotFiles"="Y"' in content
has_broken_format = '[SoftwareWine]' in content and '"ShowDotFiles"="Y"' in content
if has_broken_format and not has_correct_format:
# Fix the broken format by replacing the section header
logger.debug(f"Found broken ShowDotFiles format in {user_reg_path}, fixing...")
content = content.replace('[SoftwareWine]', '[Software\\\\Wine]')
user_reg_path.write_text(content, encoding='utf-8')
dotfiles_set_success = True
elif not has_correct_format:
logger.debug(f"Adding ShowDotFiles entry to {user_reg_path}")
with open(user_reg_path, 'a', encoding='utf-8') as f:
f.write('\n[Software\\Wine] 1603891765\n')
f.write('\n[Software\\\\Wine] 1603891765\n')
f.write('"ShowDotFiles"="Y"\n')
dotfiles_set_success = True # Count file write as success too
else:
logger.debug("ShowDotFiles already present in user.reg")
logger.debug("ShowDotFiles already present in correct format in user.reg")
dotfiles_set_success = True # Already there counts as success
else:
logger.warning(f"user.reg not found at {user_reg_path}, creating it.")
with open(user_reg_path, 'w', encoding='utf-8') as f:
f.write('[Software\\Wine] 1603891765\n')
f.write('[Software\\\\Wine] 1603891765\n')
f.write('"ShowDotFiles"="Y"\n')
dotfiles_set_success = True # Creating file counts as success
except Exception as e:
@@ -662,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"]
@@ -690,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")
@@ -759,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:
@@ -777,6 +848,56 @@ class ProtontricksHandler:
self.logger.error(f"Error running protontricks-launch: {e}")
return None
def _ensure_flatpak_cache_access(self, cache_path: Path) -> bool:
"""
Ensure flatpak protontricks has filesystem access to the winetricks cache.
Args:
cache_path: Path to winetricks cache directory
Returns:
True if access granted or already exists, False on failure
"""
if self.which_protontricks != 'flatpak':
return True # Not flatpak, no action needed
try:
# Check if flatpak already has access to this path
result = subprocess.run(
['flatpak', 'override', '--user', '--show', 'com.github.Matoking.protontricks'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
# Check if cache path is already in filesystem overrides
cache_str = str(cache_path.resolve())
if f'filesystems=' in result.stdout and cache_str in result.stdout:
self.logger.debug(f"Flatpak protontricks already has access to cache: {cache_str}")
return True
# Grant access to cache directory
self.logger.info(f"Granting flatpak protontricks access to winetricks cache: {cache_path}")
result = subprocess.run(
['flatpak', 'override', '--user', 'com.github.Matoking.protontricks',
f'--filesystem={cache_path.resolve()}'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
self.logger.info("Successfully granted flatpak protontricks cache access")
return True
else:
self.logger.warning(f"Failed to grant flatpak cache access: {result.stderr}")
return False
except Exception as e:
self.logger.warning(f"Could not configure flatpak cache access: {e}")
return False
def install_wine_components(self, appid, game_var, specific_components: Optional[List[str]] = None):
"""
Install the specified Wine components into the given prefix using protontricks.
@@ -820,6 +941,10 @@ class ProtontricksHandler:
from jackify.shared.paths import get_jackify_data_dir
jackify_cache_dir = get_jackify_data_dir() / 'winetricks_cache'
jackify_cache_dir.mkdir(parents=True, exist_ok=True)
# Ensure flatpak protontricks has access to cache (no-op for native)
self._ensure_flatpak_cache_access(jackify_cache_dir)
env['WINETRICKS_CACHE'] = str(jackify_cache_dir)
self.logger.info(f"Using winetricks cache: {jackify_cache_dir}")
if specific_components is not None:

View File

@@ -205,8 +205,8 @@ class ShortcutHandler:
time.sleep(1) # Give some time for the install to complete
# Now import it
import steam_vdf
import vdf as steam_vdf
with open(shortcuts_file, 'rb') as f:
shortcuts_data = steam_vdf.load(f)
@@ -955,7 +955,10 @@ class ShortcutHandler:
def get_appid_for_shortcut(self, shortcut_name: str, exe_path: Optional[str] = None) -> Optional[str]:
"""
Find the current AppID for a given shortcut name and (optionally) executable path using protontricks.
Find the current AppID for a given shortcut name and (optionally) executable path.
Primary method: Read directly from shortcuts.vdf (reliable, no external dependencies)
Fallback method: Use protontricks (if available)
Args:
shortcut_name (str): The name of the Steam shortcut.
@@ -965,15 +968,22 @@ class ShortcutHandler:
Optional[str]: The found AppID string, or None if not found or error occurs.
"""
self.logger.info(f"Attempting to find current AppID for shortcut: '{shortcut_name}' (exe_path: '{exe_path}')")
try:
from .protontricks_handler import ProtontricksHandler # Local import
appid = self.get_appid_from_vdf(shortcut_name, exe_path)
if appid:
self.logger.info(f"Successfully found AppID {appid} from shortcuts.vdf")
return appid
self.logger.info("AppID not found in shortcuts.vdf, trying protontricks as fallback...")
from .protontricks_handler import ProtontricksHandler
pt_handler = ProtontricksHandler(self.steamdeck)
if not pt_handler.detect_protontricks():
self.logger.error("Protontricks not detected")
self.logger.warning("Protontricks not detected - cannot use as fallback")
return None
result = pt_handler.run_protontricks("-l")
if not result or result.returncode != 0:
self.logger.error(f"Protontricks failed to list applications: {result.stderr if result else 'No result'}")
self.logger.warning(f"Protontricks fallback failed: {result.stderr if result else 'No result'}")
return None
# Build a list of all shortcuts
found_shortcuts = []
@@ -1022,8 +1032,64 @@ class ShortcutHandler:
self.logger.exception("Traceback:")
return None
def get_appid_from_vdf(self, shortcut_name: str, exe_path: Optional[str] = None) -> Optional[str]:
"""
Get AppID directly from shortcuts.vdf by reading the file and matching shortcut name/exe.
This is more reliable than using protontricks since it doesn't depend on external tools.
Args:
shortcut_name (str): The name of the Steam shortcut.
exe_path (Optional[str]): The path to the executable for additional validation.
Returns:
Optional[str]: The AppID as a string, or None if not found.
"""
self.logger.info(f"Looking up AppID from shortcuts.vdf for shortcut: '{shortcut_name}' (exe: '{exe_path}')")
if not self.shortcuts_path or not os.path.isfile(self.shortcuts_path):
self.logger.warning(f"Shortcuts.vdf not found at {self.shortcuts_path}")
return None
try:
shortcuts_data = VDFHandler.load(self.shortcuts_path, binary=True)
if not shortcuts_data or 'shortcuts' not in shortcuts_data:
self.logger.warning("No shortcuts found in shortcuts.vdf")
return None
shortcut_name_clean = shortcut_name.strip().lower()
for idx, shortcut in shortcuts_data['shortcuts'].items():
name = shortcut.get('AppName', shortcut.get('appname', '')).strip()
if name.lower() == shortcut_name_clean:
appid = shortcut.get('appid')
if appid:
if exe_path:
vdf_exe = shortcut.get('Exe', shortcut.get('exe', '')).strip('"').strip()
exe_path_norm = os.path.abspath(os.path.expanduser(exe_path)).lower()
vdf_exe_norm = os.path.abspath(os.path.expanduser(vdf_exe)).lower()
if vdf_exe_norm == exe_path_norm:
self.logger.info(f"Found AppID {appid} for shortcut '{name}' with matching exe '{vdf_exe}'")
return str(int(appid) & 0xFFFFFFFF)
else:
self.logger.debug(f"Found shortcut '{name}' but exe doesn't match: '{vdf_exe}' vs '{exe_path}'")
continue
else:
self.logger.info(f"Found AppID {appid} for shortcut '{name}' (no exe validation)")
return str(int(appid) & 0xFFFFFFFF)
self.logger.warning(f"No matching shortcut found in shortcuts.vdf for '{shortcut_name}'")
return None
except Exception as e:
self.logger.error(f"Error reading shortcuts.vdf: {e}")
self.logger.exception("Traceback:")
return None
# --- Discovery Methods Moved from ModlistHandler ---
def _scan_shortcuts_for_executable(self, executable_name: str) -> List[Dict[str, str]]:
"""
Scans the user's shortcuts.vdf file for entries pointing to a specific executable.

View File

@@ -5,6 +5,7 @@ import time
import resource
import sys
import shutil
import logging
def get_safe_python_executable():
"""
@@ -19,7 +20,6 @@ def get_safe_python_executable():
is_appimage = (
'APPIMAGE' in os.environ or
'APPDIR' in os.environ or
(hasattr(sys, 'frozen') and sys.frozen) or
(sys.argv[0] and sys.argv[0].endswith('.AppImage'))
)
@@ -41,13 +41,16 @@ def get_clean_subprocess_env(extra_env=None):
"""
Returns a copy of os.environ with bundled-runtime variables and other problematic entries removed.
Optionally merges in extra_env dict.
Also ensures bundled tools (lz4, unzip, etc.) are in PATH when running as AppImage.
CRITICAL: Preserves system PATH to ensure system tools (like lz4) are available.
Also ensures bundled tools (lz4, cabextract, winetricks) are in PATH when running as AppImage.
CRITICAL: Preserves system PATH to ensure system utilities (wget, curl, unzip, xz, gzip, sha256sum) are available.
"""
from pathlib import Path
env = os.environ.copy()
# Save APPDIR before removing it (we need it to find bundled tools)
appdir = env.get('APPDIR')
# Remove AppImage-specific variables that can confuse subprocess calls
# These variables cause subprocesses to be interpreted as new AppImage launches
for key in ['APPIMAGE', 'APPDIR', 'ARGV0', 'OWD']:
@@ -57,10 +60,10 @@ def get_clean_subprocess_env(extra_env=None):
for k in list(env):
if k.startswith('_MEIPASS'):
del env[k]
# Get current PATH - ensure we preserve system paths
current_path = env.get('PATH', '')
# Ensure common system directories are in PATH if not already present
# This is critical for tools like lz4 that might be in /usr/bin, /usr/local/bin, etc.
system_paths = ['/usr/bin', '/usr/local/bin', '/bin', '/sbin', '/usr/sbin']
@@ -68,52 +71,44 @@ def get_clean_subprocess_env(extra_env=None):
for sys_path in system_paths:
if sys_path not in path_parts and os.path.isdir(sys_path):
path_parts.append(sys_path)
# Add bundled tools directory to PATH if running as AppImage
# This ensures lz4, unzip, xz, etc. are available to subprocesses
appdir = env.get('APPDIR')
# 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: bundled tools first (if any), then original PATH with system paths
# Build final PATH: system PATH first, then bundled tools (lz4, cabextract, winetricks)
# System utilities (wget, curl, unzip, xz, gzip, sha256sum) are preferred from system
final_path_parts = []
if tools_dir and os.path.isdir(tools_dir):
# Prepend tools directory so bundled tools take precedence
# This is critical - bundled lz4 must come before system lz4
final_path_parts.append(tools_dir)
# Add all other paths (preserving order, removing duplicates)
# Note: AppRun already sets PATH with tools directory, but we ensure it's first
# Add all other paths first (system utilities take precedence)
seen = set()
if tools_dir:
seen.add(tools_dir) # Already added, don't add again
for path_part in path_parts:
if path_part and path_part not in seen:
final_path_parts.append(path_part)
seen.add(path_part)
# 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)
env['PATH'] = ':'.join(final_path_parts)
# Optionally restore LD_LIBRARY_PATH to system default if needed

View File

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

View File

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

View File

@@ -48,41 +48,56 @@ class WinetricksHandler:
self.logger.error(f"Bundled winetricks not found. Tried paths: {possible_paths}")
return None
def _get_bundled_cabextract(self) -> Optional[str]:
def _get_bundled_tool(self, tool_name: str, fallback_to_system: bool = True) -> Optional[str]:
"""
Get the path to the bundled cabextract binary, checking same locations as winetricks
Get the path to a bundled tool binary, checking same locations as winetricks.
Args:
tool_name: Name of the tool (e.g., 'cabextract', 'wget', 'unzip')
fallback_to_system: If True, fall back to system PATH if bundled version not found
Returns:
Path to the tool, or None if not found
"""
possible_paths = []
# AppImage environment - same pattern as winetricks detection
if os.environ.get('APPDIR'):
appdir_path = os.path.join(os.environ['APPDIR'], 'opt', 'jackify', 'tools', 'cabextract')
appdir_path = os.path.join(os.environ['APPDIR'], 'opt', 'jackify', 'tools', tool_name)
possible_paths.append(appdir_path)
# Development environment - relative to module location, same as winetricks
module_dir = Path(__file__).parent.parent.parent # Go from handlers/ up to jackify/
dev_path = module_dir / 'tools' / 'cabextract'
dev_path = module_dir / 'tools' / tool_name
possible_paths.append(str(dev_path))
# Try each path until we find one that works
for path in possible_paths:
if os.path.exists(path) and os.access(path, os.X_OK):
self.logger.debug(f"Found bundled cabextract at: {path}")
self.logger.debug(f"Found bundled {tool_name} at: {path}")
return str(path)
# Fallback to system PATH
try:
import shutil
system_cabextract = shutil.which('cabextract')
if system_cabextract:
self.logger.debug(f"Using system cabextract: {system_cabextract}")
return system_cabextract
except Exception:
pass
# Fallback to system PATH if requested
if fallback_to_system:
try:
import shutil
system_tool = shutil.which(tool_name)
if system_tool:
self.logger.debug(f"Using system {tool_name}: {system_tool}")
return system_tool
except Exception:
pass
self.logger.warning("Bundled cabextract not found in tools directory")
self.logger.debug(f"Bundled {tool_name} not found in tools directory")
return None
def _get_bundled_cabextract(self) -> Optional[str]:
"""
Get the path to the bundled cabextract binary.
Maintains backward compatibility with existing code.
"""
return self._get_bundled_tool('cabextract', fallback_to_system=True)
def is_available(self) -> bool:
"""
Check if winetricks is available and ready to use
@@ -149,7 +164,7 @@ class WinetricksHandler:
# If user selected a specific Proton, try that first
wine_binary = None
if user_proton_path != 'auto':
if user_proton_path and user_proton_path != 'auto':
# Check if user-selected Proton still exists
if os.path.exists(user_proton_path):
# Resolve symlinks to handle ~/.steam/steam -> ~/.local/share/Steam
@@ -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)
@@ -468,7 +604,7 @@ class WinetricksHandler:
from .protontricks_handler import ProtontricksHandler
steamdeck = os.path.exists('/home/deck')
protontricks_handler = ProtontricksHandler(steamdeck)
protontricks_available = protontricks_handler.is_available()
protontricks_available = protontricks_handler.detect_protontricks()
if protontricks_available:
self.logger.warning("=" * 80)
@@ -582,7 +718,7 @@ class WinetricksHandler:
user_proton_path = config.get_proton_path()
wine_binary = None
if user_proton_path != 'auto':
if user_proton_path and user_proton_path != 'auto':
if os.path.exists(user_proton_path):
resolved_proton_path = os.path.realpath(user_proton_path)
valve_proton_wine = os.path.join(resolved_proton_path, 'dist', 'bin', 'wine')
@@ -594,8 +730,8 @@ class WinetricksHandler:
wine_binary = ge_proton_wine
if not wine_binary:
if user_proton_path == 'auto':
self.logger.info("Auto-detecting Proton (user selected 'auto')")
if not user_proton_path or user_proton_path == 'auto':
self.logger.info("Auto-detecting Proton (user selected 'auto' or path not set)")
best_proton = WineUtils.select_best_proton()
if best_proton:
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
@@ -811,7 +947,7 @@ class WinetricksHandler:
# If user selected a specific Proton, try that first
wine_binary = None
if user_proton_path != 'auto':
if user_proton_path and user_proton_path != 'auto':
if os.path.exists(user_proton_path):
resolved_proton_path = os.path.realpath(user_proton_path)
valve_proton_wine = os.path.join(resolved_proton_path, 'dist', 'bin', 'wine')
@@ -822,10 +958,10 @@ class WinetricksHandler:
elif os.path.exists(ge_proton_wine):
wine_binary = ge_proton_wine
# Only auto-detect if user explicitly chose 'auto'
# Only auto-detect if user explicitly chose 'auto' or path is not set
if not wine_binary:
if user_proton_path == 'auto':
self.logger.info("Auto-detecting Proton (user selected 'auto')")
if not user_proton_path or user_proton_path == 'auto':
self.logger.info("Auto-detecting Proton (user selected 'auto' or path not set)")
best_proton = WineUtils.select_best_proton()
if best_proton:
wine_binary = WineUtils.find_proton_binary(best_proton['name'])

View File

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

View File

@@ -71,7 +71,7 @@ class AutomatedPrefixService:
config_handler = ConfigHandler()
user_proton_path = config_handler.get_game_proton_path()
if user_proton_path == 'auto':
if not user_proton_path or user_proton_path == 'auto':
# Use enhanced fallback logic with GE-Proton preference
logger.info("User selected auto-detect, using GE-Proton → Experimental → Proton precedence")
return WineUtils.select_best_proton()
@@ -493,54 +493,54 @@ exit"""
def detect_actual_prefix_appid(self, initial_appid: int, shortcut_name: str) -> Optional[int]:
"""
After Steam restart, detect the actual prefix AppID that was created.
Use protontricks -l to find the actual positive AppID.
Uses direct VDF file reading to find the actual AppID.
Args:
initial_appid: The initial (negative) AppID from shortcuts.vdf
shortcut_name: Name of the shortcut for logging
Returns:
The actual (positive) AppID of the created prefix, or None if not found
"""
try:
logger.info(f"Using protontricks -l to detect actual AppID for shortcut: {shortcut_name}")
# Wait up to 30 seconds for the shortcut to appear in protontricks
logger.info(f"Using VDF to detect actual AppID for shortcut: {shortcut_name}")
# Wait up to 30 seconds for Steam to process the shortcut
for i in range(30):
try:
# Use the existing protontricks handler
from jackify.backend.handlers.protontricks_handler import ProtontricksHandler
protontricks_handler = ProtontricksHandler(steamdeck or False)
result = protontricks_handler.run_protontricks('-l')
if result.returncode == 0:
lines = result.stdout.strip().split('\n')
# Look for our shortcut name in the protontricks output
for line in lines:
if shortcut_name in line and 'Non-Steam shortcut:' in line:
# Extract AppID from line like "Non-Steam shortcut: Tuxborn (3106560878)"
if '(' in line and ')' in line:
appid_str = line.split('(')[1].split(')')[0]
actual_appid = int(appid_str)
logger.info(f" Found shortcut in protontricks: {line.strip()}")
logger.info(f" Initial AppID: {initial_appid}")
logger.info(f" Actual AppID: {actual_appid}")
return actual_appid
logger.debug(f"Shortcut '{shortcut_name}' not found in protontricks yet (attempt {i+1}/30)")
time.sleep(1)
except subprocess.TimeoutExpired:
logger.warning(f"protontricks -l timed out on attempt {i+1}")
from ..handlers.shortcut_handler import ShortcutHandler
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
shortcuts_path = path_handler._find_shortcuts_vdf()
if shortcuts_path:
from ..handlers.vdf_handler import VDFHandler
shortcuts_data = VDFHandler.load(shortcuts_path, binary=True)
if shortcuts_data and 'shortcuts' in shortcuts_data:
for idx, shortcut in shortcuts_data['shortcuts'].items():
app_name = shortcut.get('AppName', shortcut.get('appname', '')).strip()
if app_name.lower() == shortcut_name.lower():
appid = shortcut.get('appid')
if appid:
actual_appid = int(appid) & 0xFFFFFFFF
logger.info(f"Found shortcut '{app_name}' in shortcuts.vdf")
logger.info(f" Initial AppID (signed): {initial_appid}")
logger.info(f" Actual AppID (unsigned): {actual_appid}")
return actual_appid
logger.debug(f"Shortcut '{shortcut_name}' not found in VDF yet (attempt {i+1}/30)")
time.sleep(1)
except Exception as e:
logger.warning(f"Error running protontricks -l on attempt {i+1}: {e}")
logger.warning(f"Error reading shortcuts.vdf on attempt {i+1}: {e}")
time.sleep(1)
logger.error(f"Shortcut '{shortcut_name}' not found in protontricks after 30 seconds")
logger.error(f"Shortcut '{shortcut_name}' not found in shortcuts.vdf after 30 seconds")
return None
except Exception as e:
logger.error(f"Error detecting actual prefix AppID: {e}")
return None
@@ -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:
@@ -3095,20 +3114,20 @@ echo Prefix creation complete.
env['WINEPREFIX'] = prefix_path
env['WINEDEBUG'] = '-all' # Suppress Wine debug output
# Registry fix 1: Set mscoree=native DLL override
# Registry fix 1: Set *mscoree=native DLL override (asterisk for full override)
# This tells Wine to use native .NET runtime instead of Wine's implementation
logger.debug("Setting mscoree=native DLL override...")
logger.debug("Setting *mscoree=native DLL override...")
cmd1 = [
wine_binary, 'reg', 'add',
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
'/v', 'mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
'/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
]
result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True, errors='replace')
if result1.returncode == 0:
logger.info("Successfully applied mscoree=native DLL override")
logger.info("Successfully applied *mscoree=native DLL override")
else:
logger.warning(f"Failed to set mscoree DLL override: {result1.stderr}")
logger.warning(f"Failed to set *mscoree DLL override: {result1.stderr}")
# Registry fix 2: Set OnlyUseLatestCLR=1
# This prevents .NET version conflicts by using the latest CLR
@@ -3140,39 +3159,96 @@ echo Prefix creation complete.
def _find_wine_binary_for_registry(self, modlist_compatdata_path: str) -> Optional[str]:
"""Find the appropriate Wine binary for registry operations"""
try:
# Method 1: Try to detect from Steam's config or use Proton from compat data
# Look for wine binary in common Proton locations
proton_paths = [
os.path.expanduser("~/.local/share/Steam/compatibilitytools.d"),
os.path.expanduser("~/.steam/steam/steamapps/common")
]
from ..handlers.config_handler import ConfigHandler
from ..handlers.wine_utils import WineUtils
# Method 1: Use the user's configured Proton version from settings
config_handler = ConfigHandler()
user_proton_path = config_handler.get_game_proton_path()
for base_path in proton_paths:
if os.path.exists(base_path):
for item in os.listdir(base_path):
if 'proton' in item.lower():
wine_path = os.path.join(base_path, item, 'files', 'bin', 'wine')
if os.path.exists(wine_path):
logger.debug(f"Found Wine binary: {wine_path}")
return wine_path
if user_proton_path and user_proton_path != 'auto':
# User has selected a specific Proton version
proton_path = Path(user_proton_path).expanduser()
# Method 2: Fallback to system wine if available
try:
result = subprocess.run(['which', 'wine'], capture_output=True, text=True)
if result.returncode == 0:
wine_path = result.stdout.strip()
logger.debug(f"Using system Wine binary: {wine_path}")
return wine_path
except Exception:
pass
# Check for wine binary in 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
]
logger.error("No suitable Wine binary found for registry operations")
for wine_path in wine_candidates:
if wine_path.exists() and wine_path.is_file():
logger.info(f"Using Wine binary from user's configured Proton: {wine_path}")
return str(wine_path)
# Wine binary not found at expected paths - search recursively in Proton directory
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:
logger.info(f"Found Wine binary via recursive search in Proton directory: {wine_binary}")
return wine_binary
logger.warning(f"User's configured Proton path has no wine binary: {user_proton_path}")
# Method 2: Fallback to auto-detection using WineUtils
best_proton = WineUtils.select_best_proton()
if best_proton:
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
if wine_binary:
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
logger.error("No suitable Proton Wine binary found for registry operations")
return None
except Exception as e:
logger.error(f"Error finding Wine binary: {e}")
return None
def _search_wine_in_proton_directory(self, proton_path: Path) -> Optional[str]:
"""
Recursively search for wine binary within a Proton directory.
This handles cases where the directory structure might differ between Proton versions.
Args:
proton_path: Path to the Proton directory to search
Returns:
Path to wine binary if found, None otherwise
"""
try:
if not proton_path.exists() or not proton_path.is_dir():
return None
# Search for 'wine' executable (not 'wine64' or 'wine-preloader')
# Limit search depth to avoid scanning entire filesystem
max_depth = 5
for root, dirs, files in os.walk(proton_path, followlinks=False):
# Calculate depth relative to proton_path
try:
depth = len(Path(root).relative_to(proton_path).parts)
except ValueError:
# Path is not relative to proton_path (shouldn't happen, but be safe)
continue
if depth > max_depth:
dirs.clear() # Don't descend further
continue
# Check if 'wine' is in this directory
if 'wine' in files:
wine_path = Path(root) / 'wine'
# Verify it's actually an executable file
if wine_path.is_file() and os.access(wine_path, os.X_OK):
logger.debug(f"Found wine binary at: {wine_path}")
return str(wine_path)
return None
except Exception as e:
logger.debug(f"Error during recursive wine search in {proton_path}: {e}")
return None
def _inject_game_registry_entries(self, modlist_compatdata_path: str):
"""Detect and inject FNV/Enderal game paths and apply universal dotnet4.x compatibility fixes"""
system_reg_path = os.path.join(modlist_compatdata_path, "pfx", "system.reg")

View File

@@ -275,8 +275,17 @@ class ModlistService:
actual_download_path = Path(download_dir_context)
download_dir_str = str(actual_download_path)
api_key = context['nexus_api_key']
# 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()
engine_dir = os.path.dirname(engine_path)
@@ -302,16 +311,30 @@ class ModlistService:
# Store original environment values (copied from working code)
original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
}
try:
# Environment setup (copied from working code)
if api_key:
# 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
elif api_key:
os.environ['NEXUS_API_KEY'] = api_key
elif 'NEXUS_API_KEY' in os.environ:
del os.environ['NEXUS_API_KEY']
else:
# No auth available, clear any inherited values
if 'NEXUS_API_KEY' in os.environ:
del os.environ['NEXUS_API_KEY']
if 'NEXUS_OAUTH_INFO' in os.environ:
del os.environ['NEXUS_OAUTH_INFO']
os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1"
pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd])
@@ -538,18 +561,42 @@ class ModlistService:
success = modlist_menu.run_modlist_configuration_phase(config_context)
debug_callback(f"Configuration phase result: {success}")
# Restore stdout before calling completion callback
# Restore stdout before ENB detection and completion callback
if original_stdout:
sys.stdout = original_stdout
original_stdout = None
# Configure ENB for Linux compatibility (non-blocking)
# Do this BEFORE completion callback so we can pass detection status
enb_detected = False
try:
from ..handlers.enb_handler import ENBHandler
enb_handler = ENBHandler()
enb_success, enb_message, enb_detected = enb_handler.configure_enb_for_linux(context.install_dir)
if enb_message:
if enb_success:
logger.info(enb_message)
if progress_callback:
progress_callback(enb_message)
else:
logger.warning(enb_message)
# Non-blocking: continue workflow even if ENB config fails
except Exception as e:
logger.warning(f"ENB configuration skipped due to error: {e}")
# Continue workflow - ENB config is optional
# Store ENB detection status in context for GUI to use
context.enb_detected = enb_detected
if completion_callback:
if success:
debug_callback("Configuration completed successfully, calling completion callback")
completion_callback(True, "Configuration completed successfully!", context.name)
# Pass ENB detection status through callback
completion_callback(True, "Configuration completed successfully!", context.name, enb_detected)
else:
debug_callback("Configuration failed, calling completion callback with failure")
completion_callback(False, "Configuration failed", context.name)
completion_callback(False, "Configuration failed", context.name, False)
return success

View File

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

View File

@@ -228,16 +228,65 @@ class NexusAuthService:
return auth_token
def get_auth_for_engine(self) -> Optional[str]:
def get_auth_for_engine(self) -> Tuple[Optional[str], Optional[str]]:
"""
Get authentication token for jackify-engine
Same as ensure_valid_auth() - engine uses NEXUS_API_KEY env var for both OAuth and API keys
(This matches upstream Wabbajack behavior)
Get authentication for jackify-engine with auto-refresh support
Returns both NEXUS_API_KEY (for backward compat) and NEXUS_OAUTH_INFO (for auto-refresh).
When NEXUS_OAUTH_INFO is provided, the engine can automatically refresh expired tokens
during long installations.
Returns:
Valid auth token to pass via NEXUS_API_KEY environment variable, or None
Tuple of (nexus_api_key, nexus_oauth_info_json)
- nexus_api_key: Access token or API key (for backward compat)
- nexus_oauth_info_json: Full OAuth state JSON (for auto-refresh) or None
"""
return self.ensure_valid_auth()
import json
import time
# Check if using OAuth and ensure token is fresh
if self.token_handler.has_token():
# Refresh token if expired (15 minute buffer for long installs)
access_token = self._get_oauth_token()
if not access_token:
logger.warning("OAuth token refresh failed, cannot provide auth to engine")
return (None, None)
# Load the refreshed token data
token_data = self.token_handler.load_token()
if token_data:
oauth_data = token_data.get('oauth', {})
# Build NexusOAuthState JSON matching upstream Wabbajack format
# This allows engine to auto-refresh tokens during long installations
nexus_oauth_state = {
"oauth": {
"access_token": oauth_data.get('access_token'),
"token_type": oauth_data.get('token_type', 'Bearer'),
"expires_in": oauth_data.get('expires_in', 3600),
"refresh_token": oauth_data.get('refresh_token'),
"scope": oauth_data.get('scope', 'public openid profile'),
"created_at": oauth_data.get('created_at', int(time.time())),
"_received_at": token_data.get('_saved_at', int(time.time())) * 10000000 + 116444736000000000 # Convert Unix to Windows FILETIME
},
"api_key": ""
}
nexus_oauth_json = json.dumps(nexus_oauth_state)
access_token = oauth_data.get('access_token')
logger.info("Providing OAuth state to engine for auto-refresh capability")
return (access_token, nexus_oauth_json)
# Fall back to API key (no auto-refresh support)
api_key = self.api_key_service.get_saved_api_key()
if api_key:
logger.info("Using API key for engine (no auto-refresh)")
return (api_key, None)
logger.warning("No authentication available for engine")
return (None, None)
def clear_all_auth(self) -> bool:
"""

View File

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

View File

@@ -15,32 +15,32 @@ _KEYWORD_PHRASES = (
)
def is_non_premium_indicator(line: str) -> bool:
def is_non_premium_indicator(line: str) -> tuple[bool, str | None]:
"""
Return True if the engine output line indicates a Nexus non-premium scenario.
Args:
line: Raw line emitted from the jackify-engine process.
Returns:
Tuple of (is_premium_error: bool, matched_pattern: str | None)
"""
if not line:
return False
return False, None
normalized = line.strip().lower()
if not normalized:
return False
return False, None
# Direct phrase detection
for phrase in _KEYWORD_PHRASES[:6]:
if phrase in normalized:
return True
if "nexus" in normalized and "premium" in normalized:
return True
return True, phrase
# Manual download + Nexus URL implies premium requirement in current workflows.
if "manual download" in normalized and ("nexusmods.com" in normalized or "nexus mods" in normalized):
return True
return True, "manual download + nexusmods.com"
return False
return False, None

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -7,7 +7,7 @@
"targets": {
".NETCoreApp,Version=v8.0": {},
".NETCoreApp,Version=v8.0/linux-x64": {
"jackify-engine/0.4.3": {
"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.3",
"Wabbajack.Downloaders.Bethesda": "0.4.3",
"Wabbajack.Downloaders.Dispatcher": "0.4.3",
"Wabbajack.Hashing.xxHash64": "0.4.3",
"Wabbajack.Networking.Discord": "0.4.3",
"Wabbajack.Networking.GitHub": "0.4.3",
"Wabbajack.Paths.IO": "0.4.3",
"Wabbajack.Server.Lib": "0.4.3",
"Wabbajack.Services.OSIntegrated": "0.4.3",
"Wabbajack.VFS": "0.4.3",
"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.3": {
"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.3"
"Wabbajack.Paths": "0.4.6"
},
"runtime": {
"Wabbajack.CLI.Builder.dll": {}
}
},
"Wabbajack.Common/0.4.3": {
"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.3",
"Wabbajack.Networking.Http": "0.4.3",
"Wabbajack.Paths.IO": "0.4.3"
"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.3": {
"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.3",
"Wabbajack.Installer": "0.4.3",
"Wabbajack.VFS": "0.4.3",
"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.3": {
"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.3",
"Wabbajack.DTOs": "0.4.3"
"Wabbajack.Common": "0.4.6",
"Wabbajack.DTOs": "0.4.6"
},
"runtime": {
"Wabbajack.Compression.BSA.dll": {}
}
},
"Wabbajack.Compression.Zip/0.4.3": {
"Wabbajack.Compression.Zip/0.4.6": {
"dependencies": {
"Wabbajack.IO.Async": "0.4.3"
"Wabbajack.IO.Async": "0.4.6"
},
"runtime": {
"Wabbajack.Compression.Zip.dll": {}
}
},
"Wabbajack.Configuration/0.4.3": {
"Wabbajack.Configuration/0.4.6": {
"runtime": {
"Wabbajack.Configuration.dll": {}
}
},
"Wabbajack.Downloaders.Bethesda/0.4.3": {
"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.3",
"Wabbajack.Downloaders.Interfaces": "0.4.3",
"Wabbajack.Networking.BethesdaNet": "0.4.3"
"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.3": {
"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.3",
"Wabbajack.Downloaders.GameFile": "0.4.3",
"Wabbajack.Downloaders.GoogleDrive": "0.4.3",
"Wabbajack.Downloaders.Http": "0.4.3",
"Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.4.3",
"Wabbajack.Downloaders.Interfaces": "0.4.3",
"Wabbajack.Downloaders.Manual": "0.4.3",
"Wabbajack.Downloaders.MediaFire": "0.4.3",
"Wabbajack.Downloaders.Mega": "0.4.3",
"Wabbajack.Downloaders.ModDB": "0.4.3",
"Wabbajack.Downloaders.Nexus": "0.4.3",
"Wabbajack.Downloaders.VerificationCache": "0.4.3",
"Wabbajack.Downloaders.WabbajackCDN": "0.4.3",
"Wabbajack.Networking.WabbajackClientApi": "0.4.3"
"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.3": {
"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.3",
"Wabbajack.VFS": "0.4.3"
"Wabbajack.Downloaders.Interfaces": "0.4.6",
"Wabbajack.VFS": "0.4.6"
},
"runtime": {
"Wabbajack.Downloaders.GameFile.dll": {}
}
},
"Wabbajack.Downloaders.GoogleDrive/0.4.3": {
"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.3",
"Wabbajack.DTOs": "0.4.3",
"Wabbajack.Downloaders.Interfaces": "0.4.3",
"Wabbajack.Networking.Http": "0.4.3",
"Wabbajack.Networking.Http.Interfaces": "0.4.3"
"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.3": {
"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.3",
"Wabbajack.DTOs": "0.4.3",
"Wabbajack.Downloaders.Interfaces": "0.4.3",
"Wabbajack.Networking.BethesdaNet": "0.4.3",
"Wabbajack.Networking.Http.Interfaces": "0.4.3",
"Wabbajack.Paths.IO": "0.4.3"
"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.3": {
"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.3",
"Wabbajack.DTOs": "0.4.3",
"Wabbajack.Paths.IO": "0.4.3"
"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.3": {
"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.3",
"Wabbajack.Downloaders.Interfaces": "0.4.3",
"Wabbajack.Networking.Http": "0.4.3",
"Wabbajack.Networking.Http.Interfaces": "0.4.3"
"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.3": {
"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.3",
"Wabbajack.Downloaders.Interfaces": "0.4.3"
"Wabbajack.Common": "0.4.6",
"Wabbajack.Downloaders.Interfaces": "0.4.6"
},
"runtime": {
"Wabbajack.Downloaders.Manual.dll": {}
}
},
"Wabbajack.Downloaders.MediaFire/0.4.3": {
"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.3",
"Wabbajack.Downloaders.Interfaces": "0.4.3",
"Wabbajack.Networking.Http.Interfaces": "0.4.3"
"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.3": {
"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.3",
"Wabbajack.Downloaders.Interfaces": "0.4.3",
"Wabbajack.Paths.IO": "0.4.3"
"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.3": {
"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.3",
"Wabbajack.Downloaders.Interfaces": "0.4.3",
"Wabbajack.Networking.Http": "0.4.3",
"Wabbajack.Networking.Http.Interfaces": "0.4.3"
"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.3": {
"Wabbajack.Downloaders.Nexus/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.4.3",
"Wabbajack.Downloaders.Interfaces": "0.4.3",
"Wabbajack.Hashing.xxHash64": "0.4.3",
"Wabbajack.Networking.Http": "0.4.3",
"Wabbajack.Networking.Http.Interfaces": "0.4.3",
"Wabbajack.Networking.NexusApi": "0.4.3",
"Wabbajack.Paths": "0.4.3"
"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.3": {
"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.3",
"Wabbajack.Paths.IO": "0.4.3"
"Wabbajack.DTOs": "0.4.6",
"Wabbajack.Paths.IO": "0.4.6"
},
"runtime": {
"Wabbajack.Downloaders.VerificationCache.dll": {}
}
},
"Wabbajack.Downloaders.WabbajackCDN/0.4.3": {
"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.3",
"Wabbajack.Downloaders.Interfaces": "0.4.3",
"Wabbajack.Networking.Http": "0.4.3",
"Wabbajack.RateLimiter": "0.4.3"
"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.3": {
"Wabbajack.DTOs/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.Hashing.xxHash64": "0.4.3",
"Wabbajack.Paths": "0.4.3"
"Wabbajack.Hashing.xxHash64": "0.4.6",
"Wabbajack.Paths": "0.4.6"
},
"runtime": {
"Wabbajack.DTOs.dll": {}
}
},
"Wabbajack.FileExtractor/0.4.3": {
"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.3",
"Wabbajack.Compression.BSA": "0.4.3",
"Wabbajack.Hashing.PHash": "0.4.3",
"Wabbajack.Paths": "0.4.3"
"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.3": {
"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.3",
"Wabbajack.DTOs": "0.4.3",
"Wabbajack.Paths": "0.4.3",
"Wabbajack.Paths.IO": "0.4.3"
"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.3": {
"Wabbajack.Hashing.xxHash64/0.4.6": {
"dependencies": {
"Wabbajack.Paths": "0.4.3",
"Wabbajack.RateLimiter": "0.4.3"
"Wabbajack.Paths": "0.4.6",
"Wabbajack.RateLimiter": "0.4.6"
},
"runtime": {
"Wabbajack.Hashing.xxHash64.dll": {}
}
},
"Wabbajack.Installer/0.4.3": {
"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.3",
"Wabbajack.Downloaders.Dispatcher": "0.4.3",
"Wabbajack.Downloaders.GameFile": "0.4.3",
"Wabbajack.FileExtractor": "0.4.3",
"Wabbajack.Networking.WabbajackClientApi": "0.4.3",
"Wabbajack.Paths": "0.4.3",
"Wabbajack.Paths.IO": "0.4.3",
"Wabbajack.VFS": "0.4.3",
"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.3": {
"Wabbajack.IO.Async/0.4.6": {
"runtime": {
"Wabbajack.IO.Async.dll": {}
}
},
"Wabbajack.Networking.BethesdaNet/0.4.3": {
"Wabbajack.Networking.BethesdaNet/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.4.3",
"Wabbajack.Networking.Http": "0.4.3",
"Wabbajack.Networking.Http.Interfaces": "0.4.3"
"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.3": {
"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.3"
"Wabbajack.Networking.Http.Interfaces": "0.4.6"
},
"runtime": {
"Wabbajack.Networking.Discord.dll": {}
}
},
"Wabbajack.Networking.GitHub/0.4.3": {
"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.3",
"Wabbajack.Networking.Http.Interfaces": "0.4.3"
"Wabbajack.DTOs": "0.4.6",
"Wabbajack.Networking.Http.Interfaces": "0.4.6"
},
"runtime": {
"Wabbajack.Networking.GitHub.dll": {}
}
},
"Wabbajack.Networking.Http/0.4.3": {
"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.3",
"Wabbajack.Downloaders.Interfaces": "0.4.3",
"Wabbajack.Hashing.xxHash64": "0.4.3",
"Wabbajack.Networking.Http.Interfaces": "0.4.3",
"Wabbajack.Paths": "0.4.3",
"Wabbajack.Paths.IO": "0.4.3"
"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.3": {
"Wabbajack.Networking.Http.Interfaces/0.4.6": {
"dependencies": {
"Wabbajack.Hashing.xxHash64": "0.4.3"
"Wabbajack.Hashing.xxHash64": "0.4.6"
},
"runtime": {
"Wabbajack.Networking.Http.Interfaces.dll": {}
}
},
"Wabbajack.Networking.NexusApi/0.4.3": {
"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.3",
"Wabbajack.Networking.Http": "0.4.3",
"Wabbajack.Networking.Http.Interfaces": "0.4.3",
"Wabbajack.Networking.WabbajackClientApi": "0.4.3"
"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.3": {
"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.3",
"Wabbajack.DTOs": "0.4.3",
"Wabbajack.Paths.IO": "0.4.3",
"Wabbajack.VFS.Interfaces": "0.4.3",
"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.3": {
"Wabbajack.Paths/0.4.6": {
"runtime": {
"Wabbajack.Paths.dll": {}
}
},
"Wabbajack.Paths.IO/0.4.3": {
"Wabbajack.Paths.IO/0.4.6": {
"dependencies": {
"Wabbajack.Paths": "0.4.3",
"Wabbajack.Paths": "0.4.6",
"shortid": "4.0.0"
},
"runtime": {
"Wabbajack.Paths.IO.dll": {}
}
},
"Wabbajack.RateLimiter/0.4.3": {
"Wabbajack.RateLimiter/0.4.6": {
"runtime": {
"Wabbajack.RateLimiter.dll": {}
}
},
"Wabbajack.Server.Lib/0.4.3": {
"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.3",
"Wabbajack.Networking.Http.Interfaces": "0.4.3",
"Wabbajack.Services.OSIntegrated": "0.4.3"
"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.3": {
"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.3",
"Wabbajack.Downloaders.Dispatcher": "0.4.3",
"Wabbajack.Installer": "0.4.3",
"Wabbajack.Networking.BethesdaNet": "0.4.3",
"Wabbajack.Networking.Discord": "0.4.3",
"Wabbajack.VFS": "0.4.3"
"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.3": {
"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.3",
"Wabbajack.FileExtractor": "0.4.3",
"Wabbajack.Hashing.PHash": "0.4.3",
"Wabbajack.Hashing.xxHash64": "0.4.3",
"Wabbajack.Paths": "0.4.3",
"Wabbajack.Paths.IO": "0.4.3",
"Wabbajack.VFS.Interfaces": "0.4.3"
"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.3": {
"Wabbajack.VFS.Interfaces/0.4.6": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.4.3",
"Wabbajack.Hashing.xxHash64": "0.4.3",
"Wabbajack.Paths": "0.4.3"
"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.3": {
"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.3": {
"Wabbajack.CLI.Builder/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Common/0.4.3": {
"Wabbajack.Common/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Compiler/0.4.3": {
"Wabbajack.Compiler/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Compression.BSA/0.4.3": {
"Wabbajack.Compression.BSA/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Compression.Zip/0.4.3": {
"Wabbajack.Compression.Zip/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Configuration/0.4.3": {
"Wabbajack.Configuration/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Bethesda/0.4.3": {
"Wabbajack.Downloaders.Bethesda/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Dispatcher/0.4.3": {
"Wabbajack.Downloaders.Dispatcher/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.GameFile/0.4.3": {
"Wabbajack.Downloaders.GameFile/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.GoogleDrive/0.4.3": {
"Wabbajack.Downloaders.GoogleDrive/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Http/0.4.3": {
"Wabbajack.Downloaders.Http/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Interfaces/0.4.3": {
"Wabbajack.Downloaders.Interfaces/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.4.3": {
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Manual/0.4.3": {
"Wabbajack.Downloaders.Manual/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.MediaFire/0.4.3": {
"Wabbajack.Downloaders.MediaFire/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Mega/0.4.3": {
"Wabbajack.Downloaders.Mega/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.ModDB/0.4.3": {
"Wabbajack.Downloaders.ModDB/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Nexus/0.4.3": {
"Wabbajack.Downloaders.Nexus/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.VerificationCache/0.4.3": {
"Wabbajack.Downloaders.VerificationCache/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.WabbajackCDN/0.4.3": {
"Wabbajack.Downloaders.WabbajackCDN/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.DTOs/0.4.3": {
"Wabbajack.DTOs/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.FileExtractor/0.4.3": {
"Wabbajack.FileExtractor/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Hashing.PHash/0.4.3": {
"Wabbajack.Hashing.PHash/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Hashing.xxHash64/0.4.3": {
"Wabbajack.Hashing.xxHash64/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Installer/0.4.3": {
"Wabbajack.Installer/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.IO.Async/0.4.3": {
"Wabbajack.IO.Async/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.BethesdaNet/0.4.3": {
"Wabbajack.Networking.BethesdaNet/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.Discord/0.4.3": {
"Wabbajack.Networking.Discord/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.GitHub/0.4.3": {
"Wabbajack.Networking.GitHub/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.Http/0.4.3": {
"Wabbajack.Networking.Http/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.Http.Interfaces/0.4.3": {
"Wabbajack.Networking.Http.Interfaces/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.NexusApi/0.4.3": {
"Wabbajack.Networking.NexusApi/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.WabbajackClientApi/0.4.3": {
"Wabbajack.Networking.WabbajackClientApi/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Paths/0.4.3": {
"Wabbajack.Paths/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Paths.IO/0.4.3": {
"Wabbajack.Paths.IO/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.RateLimiter/0.4.3": {
"Wabbajack.RateLimiter/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Server.Lib/0.4.3": {
"Wabbajack.Server.Lib/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Services.OSIntegrated/0.4.3": {
"Wabbajack.Services.OSIntegrated/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.VFS/0.4.3": {
"Wabbajack.VFS/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.VFS.Interfaces/0.4.3": {
"Wabbajack.VFS.Interfaces/0.4.6": {
"type": "project",
"serviceable": false,
"sha512": ""

Binary file not shown.

View File

@@ -16,6 +16,8 @@ from PySide6.QtWidgets import (
from PySide6.QtCore import Qt
from PySide6.QtGui import QPixmap, QIcon
from jackify.shared.paths import get_jackify_logs_dir
logger = logging.getLogger(__name__)
@@ -198,6 +200,6 @@ Modlist Install and Configuration complete!:
NOTE: If you experience ENB issues, consider using GE-Proton 10-14 instead of
Valve's Proton 10 (known ENB compatibility issues in Valve's Proton 10).
Detailed log available at: ~/Jackify/logs/Configure_New_Modlist_workflow.log"""
Detailed log available at: {get_jackify_logs_dir()}/Configure_New_Modlist_workflow.log"""
return completion_text

View File

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

View File

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

View File

@@ -22,19 +22,19 @@ if '--env-diagnostic' in sys.argv:
print("Bundled Environment Diagnostic")
print("=" * 50)
# Check if we're running from a frozen bundle
is_frozen = getattr(sys, 'frozen', False)
meipass = getattr(sys, '_MEIPASS', None)
# Check if we're running as AppImage
is_appimage = 'APPIMAGE' in os.environ or 'APPDIR' in os.environ
appdir = os.environ.get('APPDIR')
print(f"Frozen: {is_frozen}")
print(f"_MEIPASS: {meipass}")
print(f"AppImage: {is_appimage}")
print(f"APPDIR: {appdir}")
# Capture environment data
env_data = {
'timestamp': datetime.now().isoformat(),
'context': 'appimage_runtime',
'frozen': is_frozen,
'meipass': meipass,
'appimage': is_appimage,
'appdir': appdir,
'python_executable': sys.executable,
'working_directory': os.getcwd(),
'sys_path': sys.path,
@@ -737,8 +737,14 @@ class SettingsDialog(QDialog):
# Get all available Proton versions
available_protons = WineUtils.scan_all_proton_versions()
# Add "Auto" option first
self.install_proton_dropdown.addItem("Auto (Recommended)", "auto")
# Check if any Proton versions were found
has_proton = len(available_protons) > 0
# Add "Auto" or "No Proton" option first based on detection
if has_proton:
self.install_proton_dropdown.addItem("Auto (Recommended)", "auto")
else:
self.install_proton_dropdown.addItem("No Proton Versions Detected", "none")
# Filter for fast Proton versions only
fast_protons = []
@@ -893,9 +899,29 @@ class SettingsDialog(QDialog):
jackify_data_dir = self.jackify_data_dir_edit.text().strip()
self.config_handler.set("jackify_data_dir", jackify_data_dir)
# Initialize with existing config values as fallback (prevents UnboundLocalError if auto-detection fails)
resolved_install_path = self.config_handler.get("proton_path", "")
resolved_install_version = self.config_handler.get("proton_version", "")
# Save Install Proton selection - resolve "auto" to actual path
selected_install_proton_path = self.install_proton_dropdown.currentData()
if selected_install_proton_path == "auto":
if selected_install_proton_path == "none":
# No Proton detected - warn user but allow saving other settings
MessageService.warning(
self,
"No Compatible Proton Installed",
"Jackify requires Proton 9.0+, Proton Experimental, or GE-Proton 10+ to install modlists.\n\n"
"To install Proton:\n"
"1. Install any Windows game in Steam (Proton downloads automatically), OR\n"
"2. Install GE-Proton using ProtonPlus or ProtonUp-Qt, OR\n"
"3. Download GE-Proton manually from:\n"
" https://github.com/GloriousEggroll/proton-ge-custom/releases\n\n"
"Your other settings will be saved, but modlist installation may not work without Proton.",
safety_level="medium"
)
logger.warning("No Proton detected - user warned, allowing save to proceed for other settings")
# Don't modify Proton config, but continue to save other settings
elif selected_install_proton_path == "auto":
# Resolve "auto" to actual best Proton path using unified detection
try:
from jackify.backend.handlers.wine_utils import WineUtils
@@ -904,20 +930,22 @@ class SettingsDialog(QDialog):
if best_proton:
resolved_install_path = str(best_proton['path'])
resolved_install_version = best_proton['name']
self.config_handler.set("proton_path", resolved_install_path)
self.config_handler.set("proton_version", resolved_install_version)
else:
resolved_install_path = "auto"
resolved_install_version = "auto"
except:
resolved_install_path = "auto"
resolved_install_version = "auto"
# No Proton found - don't write anything, let engine auto-detect
logger.warning("Auto Proton selection failed: No Proton versions found")
# Don't modify existing config values
except Exception as e:
# Exception during detection - log it and don't write anything
logger.error(f"Auto Proton selection failed with exception: {e}", exc_info=True)
# Don't modify existing config values
else:
# User selected specific Proton version
resolved_install_path = selected_install_proton_path
# Extract version from dropdown text
resolved_install_version = self.install_proton_dropdown.currentText()
self.config_handler.set("proton_path", resolved_install_path)
self.config_handler.set("proton_version", resolved_install_version)
self.config_handler.set("proton_path", resolved_install_path)
self.config_handler.set("proton_version", resolved_install_version)
# Save Game Proton selection
selected_game_proton_path = self.game_proton_dropdown.currentData()
@@ -1038,6 +1066,10 @@ class JackifyMainWindow(QMainWindow):
self._details_extra_height = 360
self._initial_show_adjusted = False
# Track open dialogs to prevent duplicates
self._settings_dialog = None
self._about_dialog = None
# Ensure GNOME/Ubuntu exposes full set of window controls (avoid hidden buttons)
self._apply_standard_window_flags()
try:
@@ -1289,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)
@@ -1320,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:
@@ -1331,6 +1369,20 @@ class JackifyMainWindow(QMainWindow):
self.install_modlist_screen.resize_request.connect(self._on_child_resize_request)
except Exception:
pass
# Let Configure screens request window resize for expand/collapse
try:
self.configure_new_modlist_screen.resize_request.connect(self._on_child_resize_request)
except Exception:
pass
try:
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
@@ -1340,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)
@@ -1556,7 +1609,7 @@ class JackifyMainWindow(QMainWindow):
# Show update dialog after a short delay to ensure GUI is fully loaded
def show_update_dialog():
from ..dialogs.update_dialog import UpdateDialog
from .dialogs.update_dialog import UpdateDialog
dialog = UpdateDialog(update_info, self.update_service, self)
dialog.exec()
@@ -1606,23 +1659,74 @@ class JackifyMainWindow(QMainWindow):
event.accept()
def open_settings_dialog(self):
"""Open settings dialog, preventing duplicate instances"""
try:
# Check if dialog already exists and is visible
if self._settings_dialog is not None:
try:
if self._settings_dialog.isVisible():
# Dialog is already open - raise it to front
self._settings_dialog.raise_()
self._settings_dialog.activateWindow()
return
else:
# Dialog exists but is closed - clean up reference
self._settings_dialog = None
except RuntimeError:
# Dialog was deleted - clean up reference
self._settings_dialog = None
# Create new dialog
dlg = SettingsDialog(self)
self._settings_dialog = dlg
# Clean up reference when dialog is closed
def on_dialog_finished():
self._settings_dialog = None
dlg.finished.connect(on_dialog_finished)
dlg.exec()
except Exception as e:
print(f"[ERROR] Exception in open_settings_dialog: {e}")
import traceback
traceback.print_exc()
self._settings_dialog = None
def open_about_dialog(self):
"""Open about dialog, preventing duplicate instances"""
try:
from jackify.frontends.gui.dialogs.about_dialog import AboutDialog
# Check if dialog already exists and is visible
if self._about_dialog is not None:
try:
if self._about_dialog.isVisible():
# Dialog is already open - raise it to front
self._about_dialog.raise_()
self._about_dialog.activateWindow()
return
else:
# Dialog exists but is closed - clean up reference
self._about_dialog = None
except RuntimeError:
# Dialog was deleted - clean up reference
self._about_dialog = None
# Create new dialog
dlg = AboutDialog(self.system_info, self)
self._about_dialog = dlg
# Clean up reference when dialog is closed
def on_dialog_finished():
self._about_dialog = None
dlg.finished.connect(on_dialog_finished)
dlg.exec()
except Exception as e:
print(f"[ERROR] Exception in open_about_dialog: {e}")
import traceback
traceback.print_exc()
self._about_dialog = None
def _open_url(self, url: str):
"""Open URL with clean environment to avoid AppImage library conflicts."""
@@ -1762,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:
@@ -1825,8 +1925,9 @@ def main():
# Initialize file logging on root logger so all modules inherit it
from jackify.shared.logging import LoggingHandler
logging_handler = LoggingHandler()
# Rotate log file before setting up new logger
logging_handler.rotate_log_for_logger('jackify_gui', 'jackify-gui.log')
# Only rotate log file when debug mode is enabled
if debug_mode:
logging_handler.rotate_log_for_logger('jackify_gui', 'jackify-gui.log')
root_logger = logging_handler.setup_logger('', 'jackify-gui.log', is_general=True) # Empty name = root logger
if debug_mode:

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ from jackify.backend.services.api_key_service import APIKeyService
from jackify.backend.services.resolution_service import ResolutionService
from jackify.backend.handlers.config_handler import ConfigHandler
from ..dialogs import SuccessDialog
from jackify.frontends.gui.services.message_service import MessageService
def debug_print(message):
"""Print debug message only if debug mode is enabled"""
@@ -32,6 +33,7 @@ def debug_print(message):
class ConfigureExistingModlistScreen(QWidget):
steam_restart_finished = Signal(bool, str)
resize_request = Signal(str)
def __init__(self, stacked_widget=None, main_menu_index=0):
super().__init__()
debug_print("DEBUG: ConfigureExistingModlistScreen __init__ called")
@@ -220,27 +222,49 @@ class ConfigureExistingModlistScreen(QWidget):
if self.debug:
user_config_widget.setStyleSheet("border: 2px solid orange;")
user_config_widget.setToolTip("USER_CONFIG_WIDGET")
# Right: Activity window (FileProgressList widget)
# Fixed size policy to prevent shrinking when window expands
# Right: Tabbed interface with Activity and Process Monitor
# Both tabs are always available, user can switch between them
self.process_monitor = QTextEdit()
self.process_monitor.setReadOnly(True)
self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.process_monitor.setMinimumSize(QSize(300, 20))
self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;")
self.process_monitor_heading = QLabel("<b>[Process Monitor]</b>")
self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;")
self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
process_vbox = QVBoxLayout()
process_vbox.setContentsMargins(0, 0, 0, 0)
process_vbox.setSpacing(2)
process_vbox.addWidget(self.process_monitor_heading)
process_vbox.addWidget(self.process_monitor)
process_monitor_widget = QWidget()
process_monitor_widget.setLayout(process_vbox)
process_monitor_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
if self.debug:
process_monitor_widget.setStyleSheet("border: 2px solid purple;")
process_monitor_widget.setToolTip("PROCESS_MONITOR")
self.process_monitor_widget = process_monitor_widget
# Set up File Progress List (Activity tab)
self.file_progress_list.setMinimumSize(QSize(300, 20))
self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
activity_widget = QWidget()
activity_layout = QVBoxLayout()
activity_layout.setContentsMargins(0, 0, 0, 0)
activity_layout.setSpacing(0)
activity_layout.addWidget(self.file_progress_list)
activity_widget.setLayout(activity_layout)
# Create tab widget to hold both Activity and Process Monitor
self.activity_tabs = QTabWidget()
self.activity_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }")
self.activity_tabs.setContentsMargins(0, 0, 0, 0)
self.activity_tabs.setDocumentMode(False)
self.activity_tabs.setTabPosition(QTabWidget.North)
if self.debug:
activity_widget.setStyleSheet("border: 2px solid purple;")
activity_widget.setToolTip("ACTIVITY_WINDOW")
upper_hbox.addWidget(user_config_widget, stretch=11)
upper_hbox.addWidget(activity_widget, stretch=9)
self.activity_tabs.setStyleSheet("border: 2px solid cyan;")
self.activity_tabs.setToolTip("ACTIVITY_TABS")
# Keep legacy process monitor hidden (for compatibility with existing code)
self.process_monitor = QTextEdit()
self.process_monitor.setReadOnly(True)
self.process_monitor.setVisible(False) # Hidden in compact mode
# Add both widgets as tabs
self.activity_tabs.addTab(self.file_progress_list, "Activity")
self.activity_tabs.addTab(process_monitor_widget, "Process Monitor")
upper_hbox.addWidget(user_config_widget, stretch=11)
upper_hbox.addWidget(self.activity_tabs, stretch=9)
upper_hbox.setAlignment(Qt.AlignTop)
upper_section_widget = QWidget()
upper_section_widget.setLayout(upper_hbox)
@@ -490,6 +514,40 @@ class ConfigureExistingModlistScreen(QWidget):
except Exception:
pass
def _handle_progress_update(self, text):
"""Handle progress updates - update console, activity window, and progress indicator"""
# Always append to console
self._safe_append_text(text)
# Parse the message to update UI widgets
message_lower = text.lower()
# Update progress indicator based on key status messages
if "setting protontricks permissions" in message_lower or "permissions" in message_lower:
self.progress_indicator.set_status("Setting permissions...", 20)
elif "applying curated registry" in message_lower or "registry" in message_lower:
self.progress_indicator.set_status("Applying registry files...", 40)
elif "installing wine components" in message_lower or "wine component" in message_lower:
self.progress_indicator.set_status("Installing wine components...", 60)
elif "dotnet" in message_lower and "fix" in message_lower:
self.progress_indicator.set_status("Applying dotnet fixes...", 75)
elif "setting ownership" in message_lower or "ownership and permissions" in message_lower:
self.progress_indicator.set_status("Setting permissions...", 85)
elif "verifying" in message_lower:
self.progress_indicator.set_status("Verifying setup...", 90)
elif "steam integration complete" in message_lower or "configuration complete" in message_lower:
self.progress_indicator.set_status("Configuration complete", 100)
# Update activity window with generic configuration status
# Only update if message contains meaningful progress (not blank lines or separators)
if text.strip() and not text.strip().startswith('='):
# Show generic "Configuring modlist..." in activity window
self.file_progress_list.update_files(
[],
current_phase="Configuring",
summary_info={"current": 1, "total": 1, "label": "Setting up modlist"}
)
def _safe_append_text(self, text):
"""Append text with professional auto-scroll behavior"""
# Write all messages to log file
@@ -581,6 +639,8 @@ class ConfigureExistingModlistScreen(QWidget):
# Start time tracking
self._workflow_start_time = time.time()
from jackify import __version__ as jackify_version
self._safe_append_text(f"Jackify v{jackify_version}")
self._safe_append_text("[Jackify] Starting post-install configuration...")
# Create configuration thread using backend service
@@ -660,7 +720,7 @@ class ConfigureExistingModlistScreen(QWidget):
# Create and start the configuration thread
self.config_thread = ConfigurationThread(modlist_name, install_dir, resolution)
self.config_thread.progress_update.connect(self._safe_append_text)
self.config_thread.progress_update.connect(self._handle_progress_update)
self.config_thread.configuration_complete.connect(self.on_configuration_complete)
self.config_thread.error_occurred.connect(self.on_configuration_error)
self.config_thread.start()

View File

@@ -1,6 +1,7 @@
"""
ConfigureNewModlistScreen for Jackify GUI
"""
import logging
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QCheckBox, QMainWindow
from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject
from PySide6.QtGui import QPixmap, QTextCursor
@@ -28,6 +29,8 @@ from PySide6.QtWidgets import QApplication
from jackify.frontends.gui.services.message_service import MessageService
from jackify.shared.resolution_utils import get_resolution_fallback
logger = logging.getLogger(__name__)
def debug_print(message):
"""Print debug message only if debug mode is enabled"""
from jackify.backend.handlers.config_handler import ConfigHandler
@@ -97,7 +100,7 @@ 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__()
debug_print("DEBUG: ConfigureNewModlistScreen __init__ called")
@@ -300,27 +303,49 @@ class ConfigureNewModlistScreen(QWidget):
if self.debug:
user_config_widget.setStyleSheet("border: 2px solid orange;")
user_config_widget.setToolTip("USER_CONFIG_WIDGET")
# Right: Activity window (FileProgressList widget)
# Fixed size policy to prevent shrinking when window expands
# Right: Tabbed interface with Activity and Process Monitor
# Both tabs are always available, user can switch between them
self.process_monitor = QTextEdit()
self.process_monitor.setReadOnly(True)
self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.process_monitor.setMinimumSize(QSize(300, 20))
self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;")
self.process_monitor_heading = QLabel("<b>[Process Monitor]</b>")
self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;")
self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
process_vbox = QVBoxLayout()
process_vbox.setContentsMargins(0, 0, 0, 0)
process_vbox.setSpacing(2)
process_vbox.addWidget(self.process_monitor_heading)
process_vbox.addWidget(self.process_monitor)
process_monitor_widget = QWidget()
process_monitor_widget.setLayout(process_vbox)
process_monitor_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
if self.debug:
process_monitor_widget.setStyleSheet("border: 2px solid purple;")
process_monitor_widget.setToolTip("PROCESS_MONITOR")
self.process_monitor_widget = process_monitor_widget
# Set up File Progress List (Activity tab)
self.file_progress_list.setMinimumSize(QSize(300, 20))
self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
activity_widget = QWidget()
activity_layout = QVBoxLayout()
activity_layout.setContentsMargins(0, 0, 0, 0)
activity_layout.setSpacing(0)
activity_layout.addWidget(self.file_progress_list)
activity_widget.setLayout(activity_layout)
# Create tab widget to hold both Activity and Process Monitor
self.activity_tabs = QTabWidget()
self.activity_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }")
self.activity_tabs.setContentsMargins(0, 0, 0, 0)
self.activity_tabs.setDocumentMode(False)
self.activity_tabs.setTabPosition(QTabWidget.North)
if self.debug:
activity_widget.setStyleSheet("border: 2px solid purple;")
activity_widget.setToolTip("ACTIVITY_WINDOW")
upper_hbox.addWidget(user_config_widget, stretch=11)
upper_hbox.addWidget(activity_widget, stretch=9)
self.activity_tabs.setStyleSheet("border: 2px solid cyan;")
self.activity_tabs.setToolTip("ACTIVITY_TABS")
# Keep legacy process monitor hidden (for compatibility with existing code)
self.process_monitor = QTextEdit()
self.process_monitor.setReadOnly(True)
self.process_monitor.setVisible(False) # Hidden in compact mode
# Add both widgets as tabs
self.activity_tabs.addTab(self.file_progress_list, "Activity")
self.activity_tabs.addTab(process_monitor_widget, "Process Monitor")
upper_hbox.addWidget(user_config_widget, stretch=11)
upper_hbox.addWidget(self.activity_tabs, stretch=9)
upper_hbox.setAlignment(Qt.AlignTop)
upper_section_widget = QWidget()
upper_section_widget.setLayout(upper_hbox)
@@ -403,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 = []
@@ -570,18 +593,60 @@ class ConfigureNewModlistScreen(QWidget):
except Exception:
pass
def _handle_progress_update(self, text):
"""Handle progress updates - update console, activity window, and progress indicator"""
# Always append to console
self._safe_append_text(text)
# Parse the message to update UI widgets
message_lower = text.lower()
# Update progress indicator based on key status messages
if "creating steam shortcut" in message_lower:
self.progress_indicator.set_status("Creating Steam shortcut...", 10)
elif "restarting steam" in message_lower or "restart steam" in message_lower:
self.progress_indicator.set_status("Restarting Steam...", 20)
elif "steam restart" in message_lower and "success" in message_lower:
self.progress_indicator.set_status("Steam restarted successfully", 30)
elif "creating proton prefix" in message_lower or "prefix creation" in message_lower:
self.progress_indicator.set_status("Creating Proton prefix...", 50)
elif "prefix created" in message_lower or "prefix creation" in message_lower and "success" in message_lower:
self.progress_indicator.set_status("Proton prefix created", 70)
elif "applying curated registry" in message_lower or "registry" in message_lower:
self.progress_indicator.set_status("Applying registry files...", 75)
elif "installing wine components" in message_lower or "wine component" in message_lower:
self.progress_indicator.set_status("Installing wine components...", 80)
elif "dotnet" in message_lower and "fix" in message_lower:
self.progress_indicator.set_status("Applying dotnet fixes...", 85)
elif "setting ownership" in message_lower or "ownership and permissions" in message_lower:
self.progress_indicator.set_status("Setting permissions...", 90)
elif "verifying" in message_lower:
self.progress_indicator.set_status("Verifying setup...", 95)
elif "steam integration complete" in message_lower or "configuration complete" in message_lower:
self.progress_indicator.set_status("Configuration complete", 100)
# Update activity window with generic configuration status
# Only update if message contains meaningful progress (not blank lines or separators)
if text.strip() and not text.strip().startswith('='):
# Show generic "Configuring modlist..." in activity window
self.file_progress_list.update_files(
[],
current_phase="Configuring",
summary_info={"current": 1, "total": 1, "label": "Setting up modlist"}
)
def _safe_append_text(self, text):
"""Append text with professional auto-scroll behavior"""
# Write all messages to log file
self._write_to_log_file(text)
scrollbar = self.console.verticalScrollBar()
# Check if user was at bottom BEFORE adding text
was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance
# Add the text
self.console.append(text)
# Auto-scroll if user was at bottom and hasn't manually scrolled
# Re-check bottom state after text addition for better reliability
if (was_at_bottom and not self._user_manually_scrolled) or \
@@ -635,6 +700,11 @@ class ConfigureNewModlistScreen(QWidget):
if hasattr(self, 'file_progress_list'):
self.file_progress_list.stop_cpu_tracking()
# Clean up automated prefix thread if running
if hasattr(self, 'automated_prefix_thread') and self.automated_prefix_thread.isRunning():
self.automated_prefix_thread.terminate()
self.automated_prefix_thread.wait(1000)
# Clean up configuration thread if running
if hasattr(self, 'config_thread') and self.config_thread.isRunning():
self.config_thread.terminate()
@@ -782,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":
@@ -823,39 +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
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
@@ -880,6 +893,8 @@ class ConfigureNewModlistScreen(QWidget):
def _start_automated_prefix_workflow(self, modlist_name, install_dir, mo2_exe_path, resolution):
"""Start the automated prefix workflow using AutomatedPrefixService in a background thread"""
from jackify import __version__ as jackify_version
self._safe_append_text(f"Jackify v{jackify_version}")
self._safe_append_text(f"Initializing automated Steam setup for '{modlist_name}'...")
self._safe_append_text("Starting automated Steam shortcut creation and configuration...")
@@ -929,7 +944,7 @@ class ConfigureNewModlistScreen(QWidget):
# Create and start the thread
self.automated_prefix_thread = AutomatedPrefixThread(modlist_name, install_dir, mo2_exe_path, _is_steamdeck)
self.automated_prefix_thread.progress_update.connect(self._safe_append_text)
self.automated_prefix_thread.progress_update.connect(self._handle_progress_update)
self.automated_prefix_thread.workflow_complete.connect(self._on_automated_prefix_complete)
self.automated_prefix_thread.error_occurred.connect(self._on_automated_prefix_error)
self.automated_prefix_thread.start()
@@ -987,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):
@@ -1257,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):
@@ -1295,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
@@ -1323,7 +1348,7 @@ class ConfigureNewModlistScreen(QWidget):
# Start configuration thread
self.config_thread = ConfigThread(updated_context)
self.config_thread.progress_update.connect(self._safe_append_text)
self.config_thread.progress_update.connect(self._handle_progress_update)
self.config_thread.configuration_complete.connect(self.on_configuration_complete)
self.config_thread.error_occurred.connect(self.on_configuration_error)
self.config_thread.start()
@@ -1358,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):
@@ -1397,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
@@ -1424,7 +1449,7 @@ class ConfigureNewModlistScreen(QWidget):
# Create and start the configuration thread
self.config_thread = ConfigThread(updated_context)
self.config_thread.progress_update.connect(self._safe_append_text)
self.config_thread.progress_update.connect(self._handle_progress_update)
self.config_thread.configuration_complete.connect(self.on_configuration_complete)
self.config_thread.error_occurred.connect(self.on_configuration_error)
self.config_thread.start()
@@ -1433,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()
@@ -1454,6 +1479,16 @@ class ConfigureNewModlistScreen(QWidget):
parent=self
)
success_dialog.show()
# Show ENB Proton dialog if ENB was detected (use stored detection result, no re-detection)
if enb_detected:
try:
from ..dialogs.enb_proton_dialog import ENBProtonDialog
enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self)
enb_dialog.exec() # Modal dialog - blocks until user clicks OK
except Exception as e:
# Non-blocking: if dialog fails, just log and continue
logger.warning(f"Failed to show ENB dialog: {e}")
else:
self._safe_append_text(f"Configuration failed: {message}")
MessageService.critical(self, "Configuration Failed",

View File

@@ -30,7 +30,7 @@ from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicato
from jackify.backend.handlers.progress_parser import ProgressStateManager
from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
from jackify.shared.progress_models import InstallationPhase, InstallationProgress
from jackify.shared.progress_models import InstallationPhase, InstallationProgress, OperationType
# Modlist gallery (imported at module level to avoid import delay when opening dialog)
from jackify.frontends.gui.screens.modlist_gallery import ModlistGalleryDialog
@@ -409,6 +409,8 @@ class InstallModlistScreen(QWidget):
self.file_progress_list = FileProgressList() # Shows all active files (scrolls if needed)
self._premium_notice_shown = False
self._premium_failure_active = False
self._stalled_download_start_time = None # Track when downloads stall
self._stalled_download_notified = False
self._post_install_sequence = self._build_post_install_sequence()
self._post_install_total_steps = len(self._post_install_sequence)
self._post_install_current_step = 0
@@ -1517,6 +1519,16 @@ class InstallModlistScreen(QWidget):
'force_down': metadata.forceDown
}
self.modlist_name_edit.setText(metadata.title)
# Auto-append modlist name to install directory
base_install_dir = self.config_handler.get_modlist_install_base_dir()
if base_install_dir:
# Sanitize modlist title for filesystem use
import re
safe_title = re.sub(r'[<>:"/\\|?*]', '', metadata.title)
safe_title = safe_title.strip()
modlist_install_path = os.path.join(base_install_dir, safe_title)
self.install_dir_edit.setText(modlist_install_path)
finally:
if cursor_overridden:
QApplication.restoreOverrideCursor()
@@ -1859,7 +1871,7 @@ class InstallModlistScreen(QWidget):
downloads_dir = self.downloads_dir_edit.text().strip()
# Get authentication token (OAuth or API key) with automatic refresh
api_key = self.auth_service.ensure_valid_auth()
api_key, oauth_info = self.auth_service.get_auth_for_engine()
if not api_key:
self._abort_with_message(
"warning",
@@ -1871,6 +1883,26 @@ class InstallModlistScreen(QWidget):
)
return
# Log authentication status at install start (Issue #111 diagnostics)
import logging
logger = logging.getLogger(__name__)
auth_method = self.auth_service.get_auth_method()
logger.info("=" * 60)
logger.info("Authentication Status at Install Start")
logger.info(f"Method: {auth_method or 'UNKNOWN'}")
logger.info(f"Token length: {len(api_key)} chars")
if len(api_key) >= 8:
logger.info(f"Token (partial): {api_key[:4]}...{api_key[-4:]}")
if auth_method == 'oauth':
token_handler = self.auth_service.token_handler
token_info = token_handler.get_token_info()
if 'expires_in_minutes' in token_info:
logger.info(f"OAuth expires in: {token_info['expires_in_minutes']:.1f} minutes")
if token_info.get('refresh_token_likely_expired'):
logger.warning(f"OAuth refresh token age: {token_info['refresh_token_age_days']:.1f} days (may need re-auth)")
logger.info("=" * 60)
modlist_name = self.modlist_name_edit.text().strip()
missing_fields = []
if not modlist_name:
@@ -2035,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
@@ -2067,7 +2102,7 @@ class InstallModlistScreen(QWidget):
return
debug_print(f'DEBUG: Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, install_mode={install_mode}')
self.run_modlist_installer(modlist, install_dir, downloads_dir, api_key, install_mode)
self.run_modlist_installer(modlist, install_dir, downloads_dir, api_key, install_mode, oauth_info)
except Exception as e:
debug_print(f"DEBUG: Exception in validate_and_start_install: {e}")
import traceback
@@ -2078,7 +2113,7 @@ class InstallModlistScreen(QWidget):
self.cancel_install_btn.setVisible(False)
debug_print(f"DEBUG: Controls re-enabled in exception handler")
def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online'):
def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online', oauth_info=None):
debug_print('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER')
# Rotate log file at start of each workflow run (keep 5 backups)
@@ -2089,6 +2124,8 @@ class InstallModlistScreen(QWidget):
# Clear console for fresh installation output
self.console.clear()
from jackify import __version__ as jackify_version
self._safe_append_text(f"Jackify v{jackify_version}")
self._safe_append_text("Starting modlist installation with custom progress handling...")
# Update UI state for installation
@@ -2106,7 +2143,7 @@ class InstallModlistScreen(QWidget):
installation_finished = Signal(bool, str)
premium_required_detected = Signal(str)
def __init__(self, modlist, install_dir, downloads_dir, api_key, modlist_name, install_mode='online', progress_state_manager=None):
def __init__(self, modlist, install_dir, downloads_dir, api_key, modlist_name, install_mode='online', progress_state_manager=None, auth_service=None, oauth_info=None):
super().__init__()
self.modlist = modlist
self.install_dir = install_dir
@@ -2118,7 +2155,12 @@ class InstallModlistScreen(QWidget):
self.process_manager = None
# R&D: Progress state manager for parsing
self.progress_state_manager = progress_state_manager
self.auth_service = auth_service
self.oauth_info = oauth_info
self._premium_signal_sent = False
# Rolling buffer for Premium detection diagnostics
self._engine_output_buffer = []
self._buffer_size = 10
def cancel(self):
self.cancelled = True
@@ -2163,7 +2205,14 @@ class InstallModlistScreen(QWidget):
# Use clean subprocess environment to prevent AppImage variable inheritance
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
env = get_clean_subprocess_env({'NEXUS_API_KEY': self.api_key})
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;?]*[ -/]*[@-~]')
buffer = b''
@@ -2186,10 +2235,71 @@ class InstallModlistScreen(QWidget):
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False)
if not self._premium_signal_sent and is_non_premium_indicator(decoded):
# Check for Premium detection
is_premium_error, matched_pattern = is_non_premium_indicator(decoded)
if not self._premium_signal_sent and is_premium_error:
self._premium_signal_sent = True
# DIAGNOSTIC LOGGING: Capture false positive details
import logging
logger = logging.getLogger(__name__)
logger.warning("=" * 80)
logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)")
logger.warning("=" * 80)
logger.warning(f"Matched pattern: '{matched_pattern}'")
logger.warning(f"Triggering line: '{decoded.strip()}'")
# Detailed auth diagnostics
logger.warning("")
logger.warning("AUTHENTICATION DIAGNOSTICS:")
logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}")
if self.api_key:
logger.warning(f" Auth value length: {len(self.api_key)} chars")
if len(self.api_key) >= 8:
logger.warning(f" Auth value (partial): {self.api_key[:4]}...{self.api_key[-4:]}")
# Determine auth method and get detailed status
auth_method = self.auth_service.get_auth_method()
logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}")
if auth_method == 'oauth':
# Get detailed OAuth token status
token_handler = self.auth_service.token_handler
token_info = token_handler.get_token_info()
logger.warning(" OAuth Token Status:")
logger.warning(f" Has token file: {token_info.get('has_token', False)}")
logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}")
if 'expires_in_minutes' in token_info:
logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes")
logger.warning(f" Is expired: {token_info.get('is_expired', False)}")
logger.warning(f" Expires soon (5min): {token_info.get('expires_soon_5min', False)}")
if 'refresh_token_age_days' in token_info:
logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days")
logger.warning(f" Refresh token likely expired: {token_info.get('refresh_token_likely_expired', False)}")
if token_info.get('error'):
logger.warning(f" Error: {token_info['error']}")
logger.warning("")
logger.warning("Previous engine output (last 10 lines):")
for i, buffered_line in enumerate(self._engine_output_buffer, 1):
logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}")
logger.warning("")
logger.warning("If user HAS Premium, this is a FALSE POSITIVE")
logger.warning("Report to: https://github.com/Omni-guides/Jackify/issues/111")
logger.warning("=" * 80)
self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required")
# Maintain rolling buffer of engine output for diagnostics
self._engine_output_buffer.append(decoded.strip())
if len(self._engine_output_buffer) > self._buffer_size:
self._engine_output_buffer.pop(0)
# R&D: Process through progress parser
if self.progress_state_manager:
updated = self.progress_state_manager.process_line(decoded)
@@ -2211,12 +2321,71 @@ class InstallModlistScreen(QWidget):
line, buffer = buffer.split(b'\n', 1)
line = ansi_escape.sub(b'', line)
decoded = line.decode('utf-8', errors='replace')
# Notify when Nexus requires Premium before continuing
if not self._premium_signal_sent and is_non_premium_indicator(decoded):
is_premium_error, matched_pattern = is_non_premium_indicator(decoded)
if not self._premium_signal_sent and is_premium_error:
self._premium_signal_sent = True
# DIAGNOSTIC LOGGING: Capture false positive details
import logging
logger = logging.getLogger(__name__)
logger.warning("=" * 80)
logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)")
logger.warning("=" * 80)
logger.warning(f"Matched pattern: '{matched_pattern}'")
logger.warning(f"Triggering line: '{decoded.strip()}'")
# Detailed auth diagnostics
logger.warning("")
logger.warning("AUTHENTICATION DIAGNOSTICS:")
logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}")
if self.api_key:
logger.warning(f" Auth value length: {len(self.api_key)} chars")
if len(self.api_key) >= 8:
logger.warning(f" Auth value (partial): {self.api_key[:4]}...{self.api_key[-4:]}")
# Determine auth method and get detailed status
auth_method = self.auth_service.get_auth_method()
logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}")
if auth_method == 'oauth':
# Get detailed OAuth token status
token_handler = self.auth_service.token_handler
token_info = token_handler.get_token_info()
logger.warning(" OAuth Token Status:")
logger.warning(f" Has token file: {token_info.get('has_token', False)}")
logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}")
if 'expires_in_minutes' in token_info:
logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes")
logger.warning(f" Is expired: {token_info.get('is_expired', False)}")
logger.warning(f" Expires soon (5min): {token_info.get('expires_soon_5min', False)}")
if 'refresh_token_age_days' in token_info:
logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days")
logger.warning(f" Refresh token likely expired: {token_info.get('refresh_token_likely_expired', False)}")
if token_info.get('error'):
logger.warning(f" Error: {token_info['error']}")
logger.warning("")
logger.warning("Previous engine output (last 10 lines):")
for i, buffered_line in enumerate(self._engine_output_buffer, 1):
logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}")
logger.warning("")
logger.warning("If user HAS Premium, this is a FALSE POSITIVE")
logger.warning("Report to: https://github.com/Omni-guides/Jackify/issues/111")
logger.warning("=" * 80)
self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required")
# Maintain rolling buffer of engine output for diagnostics
self._engine_output_buffer.append(decoded.strip())
if len(self._engine_output_buffer) > self._buffer_size:
self._engine_output_buffer.pop(0)
# R&D: Process through progress parser
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
@@ -2298,7 +2467,9 @@ class InstallModlistScreen(QWidget):
# After the InstallationThread class definition, add:
self.install_thread = InstallationThread(
modlist, install_dir, downloads_dir, api_key, self.modlist_name_edit.text().strip(), install_mode,
progress_state_manager=self.progress_state_manager # R&D: Pass progress state manager
progress_state_manager=self.progress_state_manager, # R&D: Pass progress state manager
auth_service=self.auth_service, # Fix Issue #127: Pass auth_service for Premium detection diagnostics
oauth_info=oauth_info # Pass OAuth state for auto-refresh
)
self.install_thread.output_received.connect(self.on_installation_output)
self.install_thread.progress_received.connect(self.on_installation_progress)
@@ -2317,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
@@ -2382,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)
@@ -3586,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
@@ -3657,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",
@@ -3953,11 +4236,11 @@ class InstallModlistScreen(QWidget):
self.retry_automated_workflow_with_new_name(new_name)
elif new_name == modlist_name:
# Same name - show warning
from jackify.backend.services.message_service import MessageService
from jackify.frontends.gui.services.message_service import MessageService
MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.")
else:
# Empty name
from jackify.backend.services.message_service import MessageService
from jackify.frontends.gui.services.message_service import MessageService
MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.")
def on_cancel():
@@ -4014,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):
@@ -4054,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
@@ -4358,7 +4641,7 @@ class InstallModlistScreen(QWidget):
def _show_somnium_post_install_guidance(self):
"""Show guidance popup for Somnium post-installation steps"""
from ..widgets.message_service import MessageService
from ..services.message_service import MessageService
guidance_text = f"""<b>Somnium Post-Installation Required</b><br><br>
Due to Somnium's non-standard folder structure, you need to manually update the binary paths in ModOrganizer:<br><br>

View File

@@ -306,29 +306,49 @@ class InstallTTWScreen(QWidget):
user_config_widget.setStyleSheet("border: 2px solid orange;")
user_config_widget.setToolTip("USER_CONFIG_WIDGET")
# Right: Activity window (FileProgressList widget)
# Fixed size policy to prevent shrinking when window expands
# Right: Tabbed interface with Activity and Process Monitor
# Both tabs are always available, user can switch between them
self.file_progress_list = FileProgressList()
self.file_progress_list.setMinimumSize(QSize(300, 20))
self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
activity_widget = QWidget()
activity_layout = QVBoxLayout()
activity_layout.setContentsMargins(0, 0, 0, 0)
activity_layout.setSpacing(0)
activity_layout.addWidget(self.file_progress_list)
activity_widget.setLayout(activity_layout)
if self.debug:
activity_widget.setStyleSheet("border: 2px solid purple;")
activity_widget.setToolTip("ACTIVITY_WINDOW")
upper_hbox.addWidget(user_config_widget, stretch=11)
upper_hbox.addWidget(activity_widget, stretch=9)
# Keep legacy process monitor hidden (for compatibility with existing code)
self.process_monitor = QTextEdit()
self.process_monitor.setReadOnly(True)
self.process_monitor.setVisible(False)
self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.process_monitor.setMinimumSize(QSize(300, 20))
self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;")
self.process_monitor_heading = QLabel("<b>[Process Monitor]</b>")
self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;")
self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
process_vbox = QVBoxLayout()
process_vbox.setContentsMargins(0, 0, 0, 0)
process_vbox.setSpacing(2)
process_vbox.addWidget(self.process_monitor_heading)
process_vbox.addWidget(self.process_monitor)
process_monitor_widget = QWidget()
process_monitor_widget.setLayout(process_vbox)
process_monitor_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
if self.debug:
process_monitor_widget.setStyleSheet("border: 2px solid purple;")
process_monitor_widget.setToolTip("PROCESS_MONITOR")
self.process_monitor_widget = process_monitor_widget
# Create tab widget to hold both Activity and Process Monitor
self.activity_tabs = QTabWidget()
self.activity_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }")
self.activity_tabs.setContentsMargins(0, 0, 0, 0)
self.activity_tabs.setDocumentMode(False)
self.activity_tabs.setTabPosition(QTabWidget.North)
if self.debug:
self.activity_tabs.setStyleSheet("border: 2px solid cyan;")
self.activity_tabs.setToolTip("ACTIVITY_TABS")
# Add both widgets as tabs
self.activity_tabs.addTab(self.file_progress_list, "Activity")
self.activity_tabs.addTab(process_monitor_widget, "Process Monitor")
upper_hbox.addWidget(user_config_widget, stretch=11)
upper_hbox.addWidget(self.activity_tabs, stretch=9)
upper_hbox.setAlignment(Qt.AlignTop)
self.upper_section_widget = QWidget()
self.upper_section_widget.setLayout(upper_hbox)
@@ -2482,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
@@ -2519,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",
@@ -2815,11 +2837,11 @@ class InstallTTWScreen(QWidget):
self.retry_automated_workflow_with_new_name(new_name)
elif new_name == modlist_name:
# Same name - show warning
from jackify.backend.services.message_service import MessageService
from jackify.frontends.gui.services.message_service import MessageService
MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.")
else:
# Empty name
from jackify.backend.services.message_service import MessageService
from jackify.frontends.gui.services.message_service import MessageService
MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.")
def on_cancel():
@@ -2915,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
@@ -3494,7 +3516,7 @@ class InstallTTWScreen(QWidget):
def _show_somnium_post_install_guidance(self):
"""Show guidance popup for Somnium post-installation steps"""
from ..widgets.message_service import MessageService
from ..services.message_service import MessageService
guidance_text = f"""<b>Somnium Post-Installation Required</b><br><br>
Due to Somnium's non-standard folder structure, you need to manually update the binary paths in ModOrganizer:<br><br>

View File

@@ -833,6 +833,8 @@ class ModlistGalleryDialog(QDialog):
self._validation_update_timer = None # Timer for background validation updates
self._setup_ui()
# Disable filter controls during initial load to prevent race conditions
self._set_filter_controls_enabled(False)
# Lazy load - fetch modlists when dialog is shown
def _apply_initial_size(self):
@@ -931,27 +933,28 @@ class ModlistGalleryDialog(QDialog):
# Add spacing between Tags and Mods sections
layout.addSpacing(8)
# Mod filter
mods_label = QLabel("Mods:")
layout.addWidget(mods_label)
self.mod_search = QLineEdit()
self.mod_search.setPlaceholderText("Search mods...")
self.mod_search.setStyleSheet("QLineEdit { background: #2a2a2a; color: #fff; border: 1px solid #555; padding: 4px; }")
self.mod_search.textChanged.connect(self._filter_mods_list)
# Prevent Enter from triggering default button (which would close dialog)
self.mod_search.returnPressed.connect(lambda: self.mod_search.clearFocus())
layout.addWidget(self.mod_search)
self.mods_list = QListWidget()
self.mods_list.setSelectionMode(QListWidget.MultiSelection)
self.mods_list.setMaximumHeight(150)
self.mods_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # Remove horizontal scrollbar
self.mods_list.setStyleSheet("QListWidget { background: #2a2a2a; color: #fff; border: 1px solid #555; }")
self.mods_list.itemSelectionChanged.connect(self._apply_filters)
layout.addWidget(self.mods_list)
self.all_mods_list = [] # Store all mods for filtering
# Mod filter - TEMPORARILY DISABLED (not working correctly in v0.2.0.8)
# TODO: Re-enable once mod search index issue is resolved
# mods_label = QLabel("Mods:")
# layout.addWidget(mods_label)
#
# self.mod_search = QLineEdit()
# self.mod_search.setPlaceholderText("Search mods...")
# self.mod_search.setStyleSheet("QLineEdit { background: #2a2a2a; color: #fff; border: 1px solid #555; padding: 4px; }")
# self.mod_search.textChanged.connect(self._filter_mods_list)
# # Prevent Enter from triggering default button (which would close dialog)
# self.mod_search.returnPressed.connect(lambda: self.mod_search.clearFocus())
# layout.addWidget(self.mod_search)
#
# self.mods_list = QListWidget()
# self.mods_list.setSelectionMode(QListWidget.MultiSelection)
# self.mods_list.setMaximumHeight(150)
# self.mods_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # Remove horizontal scrollbar
# self.mods_list.setStyleSheet("QListWidget { background: #2a2a2a; color: #fff; border: 1px solid #555; }")
# self.mods_list.itemSelectionChanged.connect(self._apply_filters)
# layout.addWidget(self.mods_list)
#
# self.all_mods_list = [] # Store all mods for filtering
layout.addStretch()
@@ -1154,9 +1157,9 @@ class ModlistGalleryDialog(QDialog):
if index >= 0:
self.game_combo.setCurrentIndex(index)
# Populate tag and mod filters
# Populate tag filter (mod filter temporarily disabled)
self._populate_tag_filter()
self._populate_mod_filter()
# self._populate_mod_filter() # TEMPORARILY DISABLED
# Create cards immediately (will show placeholders for images not in cache)
self._create_all_cards()
@@ -1168,6 +1171,9 @@ class ModlistGalleryDialog(QDialog):
# Reconnect filter handler
self.game_combo.currentIndexChanged.connect(self._apply_filters)
# Enable filter controls now that data is loaded
self._set_filter_controls_enabled(True)
# Apply filters (will show all modlists for selected game initially)
self._apply_filters()
@@ -1223,9 +1229,9 @@ class ModlistGalleryDialog(QDialog):
if index >= 0:
self.game_combo.setCurrentIndex(index)
# Populate tag and mod filters
# Populate tag filter (mod filter temporarily disabled)
self._populate_tag_filter()
self._populate_mod_filter()
# self._populate_mod_filter() # TEMPORARILY DISABLED
# Create cards immediately (will show placeholders for images not in cache)
self._create_all_cards()
@@ -1335,62 +1341,83 @@ class ModlistGalleryDialog(QDialog):
def _populate_mod_filter(self):
"""Populate mod filter with all available mods from search index"""
all_mods = set()
# Track which mods come from NSFW modlists only
mods_from_nsfw_only = set()
mods_from_sfw = set()
modlists_with_mods = 0
for modlist in self.all_modlists:
if hasattr(modlist, 'mods') and modlist.mods:
modlists_with_mods += 1
for mod in modlist.mods:
all_mods.add(mod)
if modlist.nsfw:
mods_from_nsfw_only.add(mod)
else:
mods_from_sfw.add(mod)
# Mods that are ONLY in NSFW modlists (not in any SFW modlists)
self.nsfw_only_mods = mods_from_nsfw_only - mods_from_sfw
self.all_mods_list = sorted(all_mods)
self._filter_mods_list("") # Populate with all mods initially
# TEMPORARILY DISABLED - mod filter feature removed in v0.2.0.8
return
# all_mods = set()
# # Track which mods come from NSFW modlists only
# mods_from_nsfw_only = set()
# mods_from_sfw = set()
# modlists_with_mods = 0
#
# for modlist in self.all_modlists:
# if hasattr(modlist, 'mods') and modlist.mods:
# modlists_with_mods += 1
# for mod in modlist.mods:
# all_mods.add(mod)
# if modlist.nsfw:
# mods_from_nsfw_only.add(mod)
# else:
# mods_from_sfw.add(mod)
#
# # Mods that are ONLY in NSFW modlists (not in any SFW modlists)
# self.nsfw_only_mods = mods_from_nsfw_only - mods_from_sfw
#
# self.all_mods_list = sorted(all_mods)
#
# self._filter_mods_list("") # Populate with all mods initially
def _filter_mods_list(self, search_text: str = ""):
"""Filter the mods list based on search text and NSFW checkbox"""
# TEMPORARILY DISABLED - mod filter feature removed in v0.2.0.8
return
# Get search text from the widget if not provided
if not search_text and hasattr(self, 'mod_search'):
search_text = self.mod_search.text()
self.mods_list.clear()
search_lower = search_text.lower().strip()
# Start with all mods or filtered by search
if search_lower:
filtered_mods = [m for m in self.all_mods_list if search_lower in m.lower()]
else:
filtered_mods = self.all_mods_list
# Filter out NSFW-only mods if NSFW checkbox is not checked
if not self.show_nsfw.isChecked():
filtered_mods = [m for m in filtered_mods if m not in getattr(self, 'nsfw_only_mods', set())]
# Limit to first 500 results for performance
for mod in filtered_mods[:500]:
self.mods_list.addItem(mod)
if len(filtered_mods) > 500:
self.mods_list.addItem(f"... and {len(filtered_mods) - 500} more (refine search)")
# if not search_text and hasattr(self, 'mod_search'):
# search_text = self.mod_search.text()
#
# self.mods_list.clear()
# search_lower = search_text.lower().strip()
#
# # Start with all mods or filtered by search
# if search_lower:
# filtered_mods = [m for m in self.all_mods_list if search_lower in m.lower()]
# else:
# filtered_mods = self.all_mods_list
#
# # Filter out NSFW-only mods if NSFW checkbox is not checked
# if not self.show_nsfw.isChecked():
# filtered_mods = [m for m in filtered_mods if m not in getattr(self, 'nsfw_only_mods', set())]
#
# # Limit to first 500 results for performance
# for mod in filtered_mods[:500]:
# self.mods_list.addItem(mod)
#
# if len(filtered_mods) > 500:
# self.mods_list.addItem(f"... and {len(filtered_mods) - 500} more (refine search)")
def _on_nsfw_toggled(self, checked: bool):
"""Handle NSFW checkbox toggle - refresh mod list and apply filters"""
self._filter_mods_list() # Refresh mod list based on NSFW state
# self._filter_mods_list() # TEMPORARILY DISABLED - Refresh mod list based on NSFW state
self._apply_filters() # Apply all filters
def _set_filter_controls_enabled(self, enabled: bool):
"""Enable or disable all filter controls"""
self.search_box.setEnabled(enabled)
self.game_combo.setEnabled(enabled)
self.show_official_only.setEnabled(enabled)
self.show_nsfw.setEnabled(enabled)
self.hide_unavailable.setEnabled(enabled)
self.tags_list.setEnabled(enabled)
# self.mod_search.setEnabled(enabled) # TEMPORARILY DISABLED
# self.mods_list.setEnabled(enabled) # TEMPORARILY DISABLED
def _apply_filters(self):
"""Apply current filters to modlist display"""
# CRITICAL: Guard against race condition - don't filter if modlists aren't loaded yet
if not self.all_modlists:
return
filtered = self.all_modlists
# Search filter
@@ -1439,10 +1466,10 @@ class ModlistGalleryDialog(QDialog):
)
]
# Mod filter - modlist must contain ALL selected mods
selected_mods = [item.text() for item in self.mods_list.selectedItems()]
if selected_mods:
filtered = [m for m in filtered if m.mods and all(mod in m.mods for mod in selected_mods)]
# Mod filter - TEMPORARILY DISABLED (not working correctly in v0.2.0.8)
# selected_mods = [item.text() for item in self.mods_list.selectedItems()]
# if selected_mods:
# filtered = [m for m in filtered if m.mods and all(mod in m.mods for mod in selected_mods)]
self.filtered_modlists = filtered
self._update_grid()
@@ -1480,16 +1507,27 @@ class ModlistGalleryDialog(QDialog):
def _update_grid(self):
"""Update grid by removing all cards and re-adding only visible ones"""
# CRITICAL: Guard against race condition - don't update if cards aren't ready yet
if not self.all_cards:
return
# Disable updates during grid update
self.grid_widget.setUpdatesEnabled(False)
try:
# Remove all cards from layout
while self.grid_layout.count():
item = self.grid_layout.takeAt(0)
if item.widget():
item.widget().setParent(None)
# CRITICAL FIX: Properly remove all widgets to prevent overlapping
# Iterate backwards to avoid index shifting issues
for i in range(self.grid_layout.count() - 1, -1, -1):
item = self.grid_layout.takeAt(i)
widget = item.widget() if item else None
if widget:
# Hide widget during removal to prevent visual artifacts
widget.hide()
del item
# Force layout update to ensure all widgets are removed
self.grid_layout.update()
# Calculate number of columns based on available width
# Get the scroll area width (accounting for filter panel ~280px + margins)
@@ -1528,6 +1566,20 @@ class ModlistGalleryDialog(QDialog):
card = self.all_cards.get(modlist.machineURL)
if card:
# Safety check: ensure widget is not already in the layout
# (shouldn't happen after proper removal above, but defensive programming)
already_in_layout = False
for i in range(self.grid_layout.count()):
item = self.grid_layout.itemAt(i)
if item and item.widget() == card:
# Widget is already in layout - this shouldn't happen, but handle it
already_in_layout = True
self.grid_layout.removeWidget(card)
break
# Ensure widget is visible and add to grid
if not already_in_layout or card.isHidden():
card.show()
self.grid_layout.addWidget(card, row, col)
# Set column stretch - don't stretch card columns, but add a spacer column

View File

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

View File

@@ -20,6 +20,13 @@ from PySide6.QtGui import QFont
from jackify.shared.progress_models import FileProgress, OperationType
from ..shared_theme import JACKIFY_COLOR_BLUE
def _debug_log(message):
"""Log message only if debug mode is enabled"""
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
if config_handler.get('debug_mode', False):
print(message)
class SummaryProgressWidget(QWidget):
"""Widget showing summary progress for phases like Installing."""
@@ -142,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)
@@ -216,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
@@ -237,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()
@@ -264,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
@@ -288,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():
@@ -484,6 +476,13 @@ class FileProgressList(QWidget):
return
# Widget doesn't exist - create it (only clear when creating new widget)
# CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
if item:
widget = self.list_widget.itemWidget(item)
if widget:
self.list_widget.removeItemWidget(item)
self.list_widget.clear()
self._file_items.clear()
@@ -510,6 +509,10 @@ class FileProgressList(QWidget):
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
if item and item.data(Qt.UserRole) == "__summary__":
# CRITICAL FIX: Call removeItemWidget() before takeItem() to prevent orphaned widgets
widget = self.list_widget.itemWidget(item)
if widget:
self.list_widget.removeItemWidget(item)
self.list_widget.takeItem(i)
break
self._summary_widget = None
@@ -522,6 +525,10 @@ class FileProgressList(QWidget):
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
if item and item.data(Qt.UserRole) == "__transition__":
# CRITICAL FIX: Call removeItemWidget() before takeItem() to prevent orphaned widgets
widget = self.list_widget.itemWidget(item)
if widget:
self.list_widget.removeItemWidget(item)
self.list_widget.takeItem(i)
break
self._transition_label = None
@@ -533,6 +540,13 @@ class FileProgressList(QWidget):
self._show_transition_message(current_phase)
else:
# Show empty state but keep header stable
# CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
if item:
widget = self.list_widget.itemWidget(item)
if widget:
self.list_widget.removeItemWidget(item)
self.list_widget.clear()
self._file_items.clear()
@@ -579,6 +593,10 @@ class FileProgressList(QWidget):
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
if item and item.data(Qt.UserRole) == item_key:
# CRITICAL FIX: Call removeItemWidget() before takeItem() to prevent orphaned widgets
widget = self.list_widget.itemWidget(item)
if widget:
self.list_widget.removeItemWidget(item)
self.list_widget.takeItem(i)
break
del self._file_items[item_key]
@@ -638,6 +656,13 @@ class FileProgressList(QWidget):
def _show_transition_message(self, new_phase: str):
"""Show a brief 'Preparing...' message during phase transitions."""
# CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
if item:
widget = self.list_widget.itemWidget(item)
if widget:
self.list_widget.removeItemWidget(item)
self.list_widget.clear()
self._file_items.clear()
@@ -663,6 +688,13 @@ class FileProgressList(QWidget):
def clear(self):
"""Clear all file items."""
# CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
if item:
widget = self.list_widget.itemWidget(item)
if widget:
self.list_widget.removeItemWidget(item)
self.list_widget.clear()
self._file_items.clear()
self._summary_widget = None

BIN
jackify/tools/7z Executable file

Binary file not shown.

BIN
jackify/tools/sha256sum Executable file

Binary file not shown.

BIN
jackify/tools/unzip Executable file

Binary file not shown.

BIN
jackify/tools/wget Executable file

Binary file not shown.