mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-01-17 19:47:00 +01:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9000b1e080 | ||
|
|
02f3d71a82 | ||
|
|
29e1800074 | ||
|
|
9b5310c2f9 | ||
|
|
0d84d2f2fe | ||
|
|
2511c9334c | ||
|
|
5869a896a8 | ||
|
|
99fb369d5e | ||
|
|
a813236e51 | ||
|
|
a7ed4b2a1e | ||
|
|
523681a254 | ||
|
|
abfca5268f | ||
|
|
4de5c7f55d | ||
|
|
9c52c0434b |
201
CHANGELOG.md
201
CHANGELOG.md
@@ -1,5 +1,206 @@
|
||||
# Jackify Changelog
|
||||
|
||||
## v0.2.1.1 - Bug Fixes and Improvements
|
||||
**Release Date:** 2026-01-15
|
||||
|
||||
### Critical Bug Fixes
|
||||
- **AppImage Crash on Steam Deck**: Fixed `NameError: name 'Tuple' is not defined` that prevented AppImage from launching on Steam Deck. Added missing `Tuple` import to `progress_models.py`
|
||||
|
||||
### Bug Fixes
|
||||
- **Menu Routing**: Fixed "Configure Existing Modlist (In Steam)" opening wrong section (was routing to Wabbajack Installer instead of Configure Existing screen)
|
||||
- **TTW Install Dialogue**: Fixed incorrect account reference (changed "mod.db" to "ModPub" to match actual download source)
|
||||
- **Duplicate Method**: Removed duplicate `_handle_missing_downloader_error` method in winetricks handler
|
||||
- **Issue #142**: Removed sudo execution from modlist configuration - now auto-fixes permissions when possible, provides manual instructions only when sudo required
|
||||
- **Issue #133**: Updated VDF library to 4.0 for improved Steam file format compatibility (protontricks 1.13.1+ support)
|
||||
|
||||
### Features
|
||||
- **Wine Component Error Handling**: Enhanced error messages for missing downloaders with platform-specific installation instructions (SteamOS/Steam Deck vs other distros)
|
||||
|
||||
### Dependencies
|
||||
- **VDF Library**: Updated from PyPI vdf 3.4 to actively maintained solsticegamestudios/vdf 4.0 (used by Gentoo)
|
||||
- **Winetricks**: Removed bundled downloaders that caused segfaults on some systems - now uses system-provided downloaders (aria2c/wget/curl)
|
||||
|
||||
---
|
||||
|
||||
## v0.2.1 - Wabbajack Installer and ENB Support
|
||||
**Release Date:** 2025-01-12
|
||||
Y
|
||||
### 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
|
||||
|
||||
### Critical Bug Fixes
|
||||
- **OAuth Authentication**: Fixed regression in v0.2.0.4 that prevented OAuth token encryption/decryption, breaking Nexus authentication for users
|
||||
|
||||
---
|
||||
|
||||
## v0.2.0.4 - Bugfixes & Improvements
|
||||
**Release Date:** 2025-12-23
|
||||
|
||||
### Engine Updates
|
||||
- **jackify-engine 0.4.3**: Fixed case sensitivity issues, archive extraction crashes, and improved error messages
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed modlist gallery metadata showing outdated versions (now always fetches fresh data)
|
||||
- Fixed hardcoded ~/Jackify paths preventing custom data directory settings
|
||||
- Fixed update check blocking GUI startup
|
||||
- Improved Steam restart reliability (3-minute timeout, better error handling)
|
||||
- Fixed Protontricks Flatpak installation on Steam Deck
|
||||
|
||||
### Backend Changes
|
||||
- GPU texture conversion now always enabled (config setting deprecated)
|
||||
|
||||
### UI Improvements
|
||||
- Redesigned modlist detail view to show more of hero image
|
||||
- Improved gallery loading with animated feedback and faster initial load
|
||||
|
||||
---
|
||||
|
||||
## v0.2.0.3 - Engine Bugfix & Settings Cleanup
|
||||
**Release Date:** 2025-12-21
|
||||
|
||||
### Engine Updates
|
||||
- **jackify-engine 0.4.3**: Bugfix release
|
||||
|
||||
### UI Improvements
|
||||
- **Settings Dialog**: Removed GPU disable toggle - GPU usage is now always enabled (the disable option was non-functional)
|
||||
|
||||
---
|
||||
|
||||
## v0.2.0.2 - Emergency Engine Bugfix
|
||||
**Release Date:** 2025-12-18
|
||||
|
||||
### Engine Updates
|
||||
- **jackify-engine 0.4.2**: Fixed OOM issue with jackify-engine 0.4.1 due to array size
|
||||
|
||||
---
|
||||
|
||||
## v0.2.0.1 - Critical Bugfix Release
|
||||
**Release Date:** 2025-12-15
|
||||
|
||||
### Critical Bug Fixes
|
||||
- **Directory Safety Validation**: Fixed data loss bug where directories with only a `downloads/` folder were incorrectly identified as valid modlist directories
|
||||
- **Flatpak Steam Restart**: Fixed Steam restart failures on Ubuntu/PopOS by removing incompatible `-foreground` flag and increasing startup wait
|
||||
|
||||
### Bug Fixes
|
||||
- **External Links**: Fixed Ko-fi, GitHub, and Nexus links not opening on some distros using xdg-open with clean environment
|
||||
- **TTW Console Output**: Filtered standalone "OK"/"DONE" noise messages from TTW installation console
|
||||
- **Activity Window**: Fixed progress display updates in TTW Installer and other workflows
|
||||
- **Wine Component Installation**: Added status feedback during component installation showing component list
|
||||
- **Progress Parser**: Added defensive checks to prevent segfaults from malformed engine output
|
||||
- **Progress Parser Speed Info**: Fixed 'OperationType' object has no attribute 'lower' error by converting enum to string value when extracting speed info from timestamp status patterns
|
||||
|
||||
### Improvements
|
||||
- **Default Wine Components**: Added dxvk to default component list for better graphics compatibility
|
||||
- **TTW Installer UI**: Show version numbers in status displays
|
||||
|
||||
### Engine Updates
|
||||
- **jackify-engine 0.4.1**: Download reliability fixes, BSA case sensitivity handling, external drive I/O limiting, GPU detection caching, and texture processing performance improvements
|
||||
|
||||
---
|
||||
|
||||
## v0.2.0 - Modlist Gallery, OAuth Authentication & Performance Improvements
|
||||
**Release Date:** 2025-12-06
|
||||
|
||||
|
||||
@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
|
||||
Wabbajack modlists natively on Linux systems.
|
||||
"""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.2.1.1"
|
||||
|
||||
@@ -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
|
||||
@@ -680,7 +682,8 @@ class ModlistInstallCLI:
|
||||
start_time = time.time()
|
||||
|
||||
# --- BEGIN: TEE LOGGING SETUP & LOG ROTATION ---
|
||||
log_dir = Path.home() / "Jackify" / "logs"
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
log_dir = get_jackify_logs_dir()
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
workflow_log_path = log_dir / "Modlist_Install_workflow.log"
|
||||
# Log rotation: keep last 3 logs, 1MB each (adjust as needed)
|
||||
@@ -736,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()
|
||||
@@ -775,33 +788,46 @@ class ModlistInstallCLI:
|
||||
cmd.append('--debug')
|
||||
self.logger.info("Adding --debug flag to jackify-engine")
|
||||
|
||||
# Check GPU setting and add --no-gpu flag if disabled
|
||||
gpu_enabled = config_handler.get('enable_gpu_texture_conversion', True)
|
||||
if not gpu_enabled:
|
||||
cmd.append('--no-gpu')
|
||||
self.logger.info("GPU texture conversion disabled - adding --no-gpu flag to jackify-engine")
|
||||
|
||||
# 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}")
|
||||
@@ -833,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''
|
||||
@@ -845,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
|
||||
|
||||
@@ -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):
|
||||
@@ -389,6 +394,14 @@ class ConfigHandler:
|
||||
"""
|
||||
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'):
|
||||
# Fallback to base64 decode if old pycrypto is installed
|
||||
try:
|
||||
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
|
||||
except:
|
||||
return None
|
||||
|
||||
# Derive 32-byte AES key
|
||||
key = base64.urlsafe_b64decode(self._get_encryption_key())
|
||||
@@ -411,6 +424,12 @@ class ConfigHandler:
|
||||
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
|
||||
except:
|
||||
return None
|
||||
except AttributeError:
|
||||
# Old pycrypto doesn't have MODE_GCM, fallback to base64
|
||||
try:
|
||||
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
|
||||
except:
|
||||
return None
|
||||
except Exception as e:
|
||||
# Might be old base64-only format, try decoding
|
||||
try:
|
||||
@@ -740,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):
|
||||
"""
|
||||
@@ -758,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()
|
||||
@@ -766,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:
|
||||
@@ -804,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()
|
||||
|
||||
|
||||
317
jackify/backend/handlers/enb_handler.py
Normal file
317
jackify/backend/handlers/enb_handler.py
Normal file
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ENB Handler Module
|
||||
Handles ENB detection and Linux compatibility configuration for modlists.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import configparser
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ENBHandler:
|
||||
"""
|
||||
Handles ENB detection and configuration for Linux compatibility.
|
||||
|
||||
Detects ENB components in modlist installations and ensures enblocal.ini
|
||||
has the required LinuxVersion=true setting in the [GLOBAL] section.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize ENB handler."""
|
||||
self.logger = logger
|
||||
|
||||
def detect_enb_in_modlist(self, modlist_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Detect ENB components in modlist installation.
|
||||
|
||||
Searches for ENB configuration files:
|
||||
- enbseries.ini, enblocal.ini (ENB configuration files)
|
||||
|
||||
Note: Does NOT check for DLL files (d3d9.dll, d3d11.dll, dxgi.dll) as these
|
||||
are used by many other mods (ReShade, other graphics mods) and are not
|
||||
reliable indicators of ENB presence.
|
||||
|
||||
Args:
|
||||
modlist_path: Path to modlist installation directory
|
||||
|
||||
Returns:
|
||||
Dict with detection results:
|
||||
- has_enb: bool - True if ENB config files found
|
||||
- enblocal_ini: str or None - Path to enblocal.ini if found
|
||||
- enbseries_ini: str or None - Path to enbseries.ini if found
|
||||
- d3d9_dll: str or None - Always None (not checked)
|
||||
- d3d11_dll: str or None - Always None (not checked)
|
||||
- dxgi_dll: str or None - Always None (not checked)
|
||||
"""
|
||||
enb_info = {
|
||||
'has_enb': False,
|
||||
'enblocal_ini': None,
|
||||
'enbseries_ini': None,
|
||||
'd3d9_dll': None,
|
||||
'd3d11_dll': None,
|
||||
'dxgi_dll': None
|
||||
}
|
||||
|
||||
if not modlist_path.exists():
|
||||
self.logger.warning(f"Modlist path does not exist: {modlist_path}")
|
||||
return enb_info
|
||||
|
||||
# Search for ENB indicator files
|
||||
# IMPORTANT: Only check for ENB config files (enbseries.ini, enblocal.ini)
|
||||
# Do NOT check for DLL files (d3d9.dll, d3d11.dll, dxgi.dll) as these are used
|
||||
# by many other mods (ReShade, other graphics mods) and are not reliable ENB indicators
|
||||
|
||||
enb_config_patterns = [
|
||||
('**/enbseries.ini', 'enbseries_ini'),
|
||||
('**/enblocal.ini', 'enblocal_ini')
|
||||
]
|
||||
|
||||
for pattern, key in enb_config_patterns:
|
||||
for file_path in modlist_path.glob(pattern):
|
||||
# Skip backups and plugin data directories
|
||||
if "Backup" in str(file_path) or "plugins/data" in str(file_path):
|
||||
continue
|
||||
|
||||
enb_info['has_enb'] = True
|
||||
if not enb_info[key]: # Store first match
|
||||
enb_info[key] = str(file_path)
|
||||
|
||||
# If we detected ENB config but didn't find enblocal.ini via glob,
|
||||
# use the priority-based finder
|
||||
if enb_info['has_enb'] and not enb_info['enblocal_ini']:
|
||||
found_ini = self.find_enblocal_ini(modlist_path)
|
||||
if found_ini:
|
||||
enb_info['enblocal_ini'] = str(found_ini)
|
||||
|
||||
return enb_info
|
||||
|
||||
def find_enblocal_ini(self, modlist_path: Path) -> Optional[Path]:
|
||||
"""
|
||||
Find enblocal.ini in modlist installation using priority-based search.
|
||||
|
||||
Search order (highest priority first):
|
||||
1. Stock Game/Game Root directories (active locations)
|
||||
2. Mods folder with Root/root subfolder (most common pattern)
|
||||
3. Direct in mods/fixes folders
|
||||
4. Fallback recursive search (excluding backups)
|
||||
|
||||
Args:
|
||||
modlist_path: Path to modlist installation directory
|
||||
|
||||
Returns:
|
||||
Path to enblocal.ini if found, None otherwise
|
||||
"""
|
||||
if not modlist_path.exists():
|
||||
return None
|
||||
|
||||
# Priority 1: Stock Game/Game Root (active locations)
|
||||
stock_game_names = [
|
||||
"Stock Game",
|
||||
"Game Root",
|
||||
"STOCK GAME",
|
||||
"Stock Game Folder",
|
||||
"Stock Folder",
|
||||
"Skyrim Stock"
|
||||
]
|
||||
|
||||
for name in stock_game_names:
|
||||
candidate = modlist_path / name / "enblocal.ini"
|
||||
if candidate.exists():
|
||||
self.logger.debug(f"Found enblocal.ini in Stock Game location: {candidate}")
|
||||
return candidate
|
||||
|
||||
# Priority 2: Mods folder with Root/root subfolder
|
||||
mods_dir = modlist_path / "mods"
|
||||
if mods_dir.exists():
|
||||
# Search for Root/root subfolders
|
||||
for root_dir in mods_dir.rglob("Root"):
|
||||
candidate = root_dir / "enblocal.ini"
|
||||
if candidate.exists():
|
||||
self.logger.debug(f"Found enblocal.ini in mods/Root: {candidate}")
|
||||
return candidate
|
||||
|
||||
for root_dir in mods_dir.rglob("root"):
|
||||
candidate = root_dir / "enblocal.ini"
|
||||
if candidate.exists():
|
||||
self.logger.debug(f"Found enblocal.ini in mods/root: {candidate}")
|
||||
return candidate
|
||||
|
||||
# Priority 3: Direct in mods/fixes folders
|
||||
for search_dir in [modlist_path / "mods", modlist_path / "fixes"]:
|
||||
if search_dir.exists():
|
||||
for enb_file in search_dir.rglob("enblocal.ini"):
|
||||
# Skip backups and plugin data
|
||||
if "Backup" not in str(enb_file) and "plugins/data" not in str(enb_file):
|
||||
self.logger.debug(f"Found enblocal.ini in {search_dir.name}: {enb_file}")
|
||||
return enb_file
|
||||
|
||||
# Priority 4: Fallback recursive search (exclude backups)
|
||||
for enb_file in modlist_path.rglob("enblocal.ini"):
|
||||
if "Backup" not in str(enb_file) and "plugins/data" not in str(enb_file):
|
||||
self.logger.debug(f"Found enblocal.ini via recursive search: {enb_file}")
|
||||
return enb_file
|
||||
|
||||
return None
|
||||
|
||||
def ensure_linux_version_setting(self, enblocal_ini_path: Path) -> bool:
|
||||
"""
|
||||
Safely ensure [GLOBAL] section exists with LinuxVersion=true in enblocal.ini.
|
||||
|
||||
Safety features:
|
||||
- Verifies file exists before attempting modification
|
||||
- Checks if [GLOBAL] section exists before adding (prevents duplicates)
|
||||
- Creates backup before any write operation
|
||||
- Only writes if changes are actually needed
|
||||
- Handles encoding issues gracefully
|
||||
- Preserves existing file structure and comments
|
||||
|
||||
Args:
|
||||
enblocal_ini_path: Path to enblocal.ini file
|
||||
|
||||
Returns:
|
||||
bool: True if successful or no changes needed, False on error
|
||||
"""
|
||||
try:
|
||||
# Safety check: file must exist
|
||||
if not enblocal_ini_path.exists():
|
||||
self.logger.warning(f"enblocal.ini not found at: {enblocal_ini_path}")
|
||||
return False
|
||||
|
||||
# Read existing INI with same settings as modlist_handler.py
|
||||
config = configparser.ConfigParser(
|
||||
allow_no_value=True,
|
||||
delimiters=['=']
|
||||
)
|
||||
config.optionxform = str # Preserve case sensitivity
|
||||
|
||||
# Read with encoding handling (same pattern as modlist_handler.py)
|
||||
try:
|
||||
with open(enblocal_ini_path, 'r', encoding='utf-8-sig') as f:
|
||||
config.read_file(f)
|
||||
except UnicodeDecodeError:
|
||||
with open(enblocal_ini_path, 'r', encoding='latin-1') as f:
|
||||
config.read_file(f)
|
||||
except configparser.DuplicateSectionError as e:
|
||||
# If file has duplicate [GLOBAL] sections, log warning and skip
|
||||
self.logger.warning(f"enblocal.ini has duplicate sections: {e}. Skipping modification.")
|
||||
return False
|
||||
|
||||
# Check if [GLOBAL] section exists (case-insensitive check)
|
||||
global_section_exists = False
|
||||
global_section_name = None
|
||||
|
||||
# Find existing [GLOBAL] section (case-insensitive)
|
||||
for section_name in config.sections():
|
||||
if section_name.upper() == 'GLOBAL':
|
||||
global_section_exists = True
|
||||
global_section_name = section_name # Use actual case
|
||||
break
|
||||
|
||||
# Check current LinuxVersion value
|
||||
needs_update = False
|
||||
if global_section_exists:
|
||||
# Section exists - check if LinuxVersion needs updating
|
||||
current_value = config.get(global_section_name, 'LinuxVersion', fallback=None)
|
||||
if current_value is None or current_value.lower() != 'true':
|
||||
needs_update = True
|
||||
else:
|
||||
# Section doesn't exist - we need to add it
|
||||
needs_update = True
|
||||
|
||||
# If no changes needed, return success
|
||||
if not needs_update:
|
||||
self.logger.debug(f"enblocal.ini already has LinuxVersion=true in [GLOBAL] section")
|
||||
return True
|
||||
|
||||
# Changes needed - create backup first
|
||||
backup_path = enblocal_ini_path.with_suffix('.ini.jackify_backup')
|
||||
try:
|
||||
if not backup_path.exists():
|
||||
shutil.copy2(enblocal_ini_path, backup_path)
|
||||
self.logger.debug(f"Created backup: {backup_path}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to create backup: {e}. Proceeding anyway.")
|
||||
|
||||
# Make changes
|
||||
if not global_section_exists:
|
||||
# Add [GLOBAL] section (configparser will use exact case 'GLOBAL')
|
||||
config.add_section('GLOBAL')
|
||||
global_section_name = 'GLOBAL'
|
||||
self.logger.debug("Added [GLOBAL] section to enblocal.ini")
|
||||
|
||||
# Set LinuxVersion=true
|
||||
config.set(global_section_name, 'LinuxVersion', 'true')
|
||||
self.logger.debug(f"Set LinuxVersion=true in [GLOBAL] section")
|
||||
|
||||
# Write back to file
|
||||
with open(enblocal_ini_path, 'w', encoding='utf-8') as f:
|
||||
config.write(f, space_around_delimiters=False)
|
||||
|
||||
self.logger.info(f"Successfully configured enblocal.ini: {enblocal_ini_path}")
|
||||
return True
|
||||
|
||||
except configparser.DuplicateSectionError as e:
|
||||
# Handle duplicate sections gracefully
|
||||
self.logger.error(f"enblocal.ini has duplicate [GLOBAL] sections: {e}")
|
||||
return False
|
||||
except configparser.Error as e:
|
||||
# Handle other configparser errors
|
||||
self.logger.error(f"ConfigParser error reading enblocal.ini: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
# Handle any other errors
|
||||
self.logger.error(f"Unexpected error configuring enblocal.ini: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def configure_enb_for_linux(self, modlist_path: Path) -> Tuple[bool, Optional[str], bool]:
|
||||
"""
|
||||
Main entry point: detect ENB and configure enblocal.ini.
|
||||
|
||||
Safe for modlists without ENB - returns success with no message.
|
||||
|
||||
Args:
|
||||
modlist_path: Path to modlist installation directory
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Optional[str], bool]: (success, message, enb_detected)
|
||||
- success: True if successful or no ENB detected, False on error
|
||||
- message: Human-readable message (None if no action taken)
|
||||
- enb_detected: True if ENB was detected, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Step 1: Detect ENB (safe - just searches for files)
|
||||
enb_info = self.detect_enb_in_modlist(modlist_path)
|
||||
enb_detected = enb_info.get('has_enb', False)
|
||||
|
||||
# Step 2: If no ENB detected, return success (no action needed)
|
||||
if not enb_detected:
|
||||
return (True, None, False) # Safe: no ENB, nothing to do
|
||||
|
||||
# Step 3: Find enblocal.ini
|
||||
enblocal_path = enb_info.get('enblocal_ini')
|
||||
if not enblocal_path:
|
||||
# ENB detected but no enblocal.ini found - this is unusual but not an error
|
||||
self.logger.warning("ENB detected but enblocal.ini not found - may be configured elsewhere")
|
||||
return (True, None, True) # ENB detected but no config file
|
||||
|
||||
# Step 4: Configure enblocal.ini (safe method with all checks)
|
||||
enblocal_path_obj = Path(enblocal_path)
|
||||
success = self.ensure_linux_version_setting(enblocal_path_obj)
|
||||
|
||||
if success:
|
||||
return (True, "ENB configured for Linux compatibility", True)
|
||||
else:
|
||||
# Non-blocking: log error but don't fail workflow
|
||||
return (False, "Failed to configure ENB (see logs for details)", True)
|
||||
|
||||
except Exception as e:
|
||||
# Catch-all error handling - never break the workflow
|
||||
self.logger.error(f"Error in ENB configuration: {e}", exc_info=True)
|
||||
return (False, "ENB configuration error (see logs)", False)
|
||||
|
||||
@@ -604,6 +604,11 @@ class FileSystemHandler:
|
||||
"""
|
||||
Create required directories for a game modlist
|
||||
|
||||
This includes both Linux home directories and Wine prefix directories.
|
||||
Creating the Wine prefix Documents directories is critical for USVFS
|
||||
to work properly on first launch - USVFS needs the target directory
|
||||
to exist before it can virtualize profile INI files.
|
||||
|
||||
Args:
|
||||
game_name: Name of the game (e.g., skyrimse, fallout4)
|
||||
appid: Steam AppID of the modlist
|
||||
@@ -614,13 +619,24 @@ class FileSystemHandler:
|
||||
try:
|
||||
# Define base paths
|
||||
home_dir = os.path.expanduser("~")
|
||||
|
||||
# Game-specific Documents directory names (for both Linux home and Wine prefix)
|
||||
game_docs_dirs = {
|
||||
"skyrimse": "Skyrim Special Edition",
|
||||
"fallout4": "Fallout4",
|
||||
"falloutnv": "FalloutNV",
|
||||
"oblivion": "Oblivion",
|
||||
"enderal": "Enderal Special Edition",
|
||||
"enderalse": "Enderal Special Edition"
|
||||
}
|
||||
|
||||
game_dirs = {
|
||||
# Common directories needed across all games
|
||||
"common": [
|
||||
os.path.join(home_dir, ".local", "share", "Steam", "steamapps", "compatdata", appid, "pfx"),
|
||||
os.path.join(home_dir, ".steam", "steam", "steamapps", "compatdata", appid, "pfx")
|
||||
],
|
||||
# Game-specific directories
|
||||
# Game-specific directories in Linux home (legacy, may not be needed)
|
||||
"skyrimse": [
|
||||
os.path.join(home_dir, "Documents", "My Games", "Skyrim Special Edition"),
|
||||
],
|
||||
@@ -635,18 +651,52 @@ class FileSystemHandler:
|
||||
]
|
||||
}
|
||||
|
||||
# Create common directories
|
||||
# Create common directories (compatdata pfx paths)
|
||||
for dir_path in game_dirs["common"]:
|
||||
if dir_path and os.path.exists(os.path.dirname(dir_path)):
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
self.logger.debug(f"Created directory: {dir_path}")
|
||||
|
||||
# Create game-specific directories
|
||||
# Create game-specific directories in Linux home (legacy support)
|
||||
if game_name in game_dirs:
|
||||
for dir_path in game_dirs[game_name]:
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
self.logger.debug(f"Created game-specific directory: {dir_path}")
|
||||
|
||||
# CRITICAL: Create game-specific Documents directories in Wine prefix
|
||||
# This is required for USVFS to virtualize profile INI files on first launch
|
||||
if game_name in game_docs_dirs:
|
||||
docs_dir_name = game_docs_dirs[game_name]
|
||||
|
||||
# Find compatdata path for this AppID
|
||||
from ..handlers.path_handler import PathHandler
|
||||
path_handler = PathHandler()
|
||||
compatdata_path = path_handler.find_compat_data(appid)
|
||||
|
||||
if compatdata_path:
|
||||
# Create Documents/My Games/{GameName} in Wine prefix
|
||||
wine_docs_path = os.path.join(
|
||||
str(compatdata_path),
|
||||
"pfx",
|
||||
"drive_c",
|
||||
"users",
|
||||
"steamuser",
|
||||
"Documents",
|
||||
"My Games",
|
||||
docs_dir_name
|
||||
)
|
||||
|
||||
try:
|
||||
os.makedirs(wine_docs_path, exist_ok=True)
|
||||
self.logger.info(f"Created Wine prefix Documents directory for USVFS: {wine_docs_path}")
|
||||
self.logger.debug(f"This allows USVFS to virtualize profile INI files on first launch")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not create Wine prefix Documents directory {wine_docs_path}: {e}")
|
||||
# Don't fail completely - this is a first-launch optimization
|
||||
else:
|
||||
self.logger.warning(f"Could not find compatdata path for AppID {appid}, skipping Wine prefix Documents directory creation")
|
||||
self.logger.debug("Wine prefix Documents directories will be created when game runs for first time")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating required directories: {e}")
|
||||
@@ -671,59 +721,75 @@ class FileSystemHandler:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool:
|
||||
"""Change ownership and permissions using sudo (robust, with timeout and re-prompt)."""
|
||||
def verify_ownership_and_permissions(path: Path) -> tuple[bool, str]:
|
||||
"""
|
||||
Verify and fix ownership/permissions for modlist directory.
|
||||
Returns (success, error_message).
|
||||
|
||||
Logic:
|
||||
- If files NOT owned by user: Can't fix without sudo, return error with instructions
|
||||
- If files owned by user: Try to fix permissions ourselves with chmod
|
||||
"""
|
||||
if not path.exists():
|
||||
logger.error(f"Path does not exist: {path}")
|
||||
return False
|
||||
# Check if all files/dirs are already owned by the user
|
||||
if FileSystemHandler.all_owned_by_user(path):
|
||||
logger.info(f"All files in {path} are already owned by the current user. Skipping sudo chown/chmod.")
|
||||
return True
|
||||
return False, f"Path does not exist: {path}"
|
||||
|
||||
# Check if all files/dirs are owned by the user
|
||||
if not FileSystemHandler.all_owned_by_user(path):
|
||||
# Files not owned by us - need sudo to fix
|
||||
try:
|
||||
user_name = pwd.getpwuid(os.geteuid()).pw_name
|
||||
group_name = grp.getgrgid(os.geteuid()).gr_name
|
||||
except KeyError:
|
||||
logger.error("Could not determine current user or group name.")
|
||||
return False, "Could not determine current user or group name."
|
||||
|
||||
logger.error(f"Ownership issue detected: Some files in {path} are not owned by {user_name}")
|
||||
|
||||
error_msg = (
|
||||
f"\nOwnership Issue Detected\n"
|
||||
f"Some files in the modlist directory are not owned by your user account.\n"
|
||||
f"This can happen if the modlist was copied from another location or installed by a different user.\n\n"
|
||||
f"To fix this, open a terminal and run:\n\n"
|
||||
f" sudo chown -R {user_name}:{group_name} \"{path}\"\n"
|
||||
f" sudo chmod -R 755 \"{path}\"\n\n"
|
||||
f"After running these commands, retry the configuration process."
|
||||
)
|
||||
return False, error_msg
|
||||
|
||||
# Files are owned by us - try to fix permissions ourselves
|
||||
logger.info(f"Files in {path} are owned by current user, verifying permissions...")
|
||||
try:
|
||||
user_name = pwd.getpwuid(os.geteuid()).pw_name
|
||||
group_name = grp.getgrgid(os.geteuid()).gr_name
|
||||
except KeyError:
|
||||
logger.error("Could not determine current user or group name.")
|
||||
return False
|
||||
result = subprocess.run(
|
||||
['chmod', '-R', '755', str(path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info(f"Permissions set successfully for {path}")
|
||||
return True, ""
|
||||
else:
|
||||
logger.warning(f"chmod returned non-zero but we'll continue: {result.stderr}")
|
||||
# Non-critical if chmod fails on our own files, might be read-only filesystem or similar
|
||||
return True, ""
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running chmod: {e}, continuing anyway")
|
||||
# Non-critical error, we own the files so proceed
|
||||
return True, ""
|
||||
|
||||
log_msg = f"Applying ownership/permissions for {path} (user: {user_name}, group: {group_name}) via sudo."
|
||||
logger.info(log_msg)
|
||||
if status_callback:
|
||||
status_callback(f"Setting ownership/permissions for {os.path.basename(str(path))}...")
|
||||
else:
|
||||
print(f'\n{COLOR_PROMPT}Adjusting permissions for {path} (may require sudo password)...{COLOR_RESET}')
|
||||
|
||||
def run_sudo_with_retries(cmd, desc, max_retries=3, timeout=300):
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
logger.info(f"Running sudo command (attempt {attempt+1}/{max_retries}): {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=timeout)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
else:
|
||||
logger.error(f"sudo {desc} failed. Error: {result.stderr.strip()}")
|
||||
print(f"Error: Failed to {desc}. Check logs.")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"sudo {desc} timed out (attempt {attempt+1}/{max_retries}).")
|
||||
print(f"\nSudo prompt timed out after {timeout} seconds. Please try again.")
|
||||
# Flush input if possible, then retry
|
||||
print(f"Failed to {desc} after {max_retries} attempts. Aborting.")
|
||||
return False
|
||||
|
||||
# Run chown with retries
|
||||
chown_command = ['sudo', 'chown', '-R', f'{user_name}:{group_name}', str(path)]
|
||||
if not run_sudo_with_retries(chown_command, "change ownership"):
|
||||
return False
|
||||
print()
|
||||
# Run chmod with retries
|
||||
chmod_command = ['sudo', 'chmod', '-R', '755', str(path)]
|
||||
if not run_sudo_with_retries(chmod_command, "set permissions"):
|
||||
return False
|
||||
print()
|
||||
logger.info("Permissions set successfully.")
|
||||
return True
|
||||
@staticmethod
|
||||
def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool:
|
||||
"""
|
||||
DEPRECATED: Use verify_ownership_and_permissions() instead.
|
||||
This method is kept for backwards compatibility but no longer executes sudo.
|
||||
"""
|
||||
logger.warning("set_ownership_and_permissions_sudo() is deprecated - use verify_ownership_and_permissions()")
|
||||
success, error_msg = FileSystemHandler.verify_ownership_and_permissions(path)
|
||||
if not success:
|
||||
logger.error(error_msg)
|
||||
print(error_msg)
|
||||
return success
|
||||
|
||||
def download_file(self, url: str, destination_path: Path, overwrite: bool = False, quiet: bool = False) -> bool:
|
||||
"""Downloads a file from a URL to a destination path."""
|
||||
|
||||
@@ -1196,7 +1196,8 @@ class InstallWabbajackHandler:
|
||||
"""Displays the final success message and next steps."""
|
||||
# Basic log file path (assuming standard location)
|
||||
# TODO: Get log file path more reliably if needed
|
||||
log_path = Path.home() / "Jackify" / "logs" / "jackify-cli.log"
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
log_path = get_jackify_logs_dir() / "jackify-cli.log"
|
||||
|
||||
print("\n───────────────────────────────────────────────────────────────────")
|
||||
print(f"{COLOR_INFO}Wabbajack Installation Completed Successfully!{COLOR_RESET}")
|
||||
|
||||
@@ -14,14 +14,15 @@ import shutil
|
||||
class LoggingHandler:
|
||||
"""
|
||||
Central logging handler for Jackify.
|
||||
- Uses ~/Jackify/logs/ as the log directory.
|
||||
- Uses configured Jackify data directory for logs (default: ~/Jackify/logs/).
|
||||
- Supports per-function log files (e.g., jackify-install-wabbajack.log).
|
||||
- Handles log rotation and log directory creation.
|
||||
Usage:
|
||||
logger = LoggingHandler().setup_logger('install_wabbajack', 'jackify-install-wabbajack.log')
|
||||
"""
|
||||
def __init__(self):
|
||||
self.log_dir = Path.home() / "Jackify" / "logs"
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
self.log_dir = get_jackify_logs_dir()
|
||||
self.ensure_log_directory()
|
||||
|
||||
def ensure_log_directory(self) -> None:
|
||||
@@ -185,5 +186,5 @@ class LoggingHandler:
|
||||
return stats
|
||||
|
||||
def get_general_logger(self):
|
||||
"""Get the general CLI logger (~/Jackify/logs/jackify-cli.log)."""
|
||||
"""Get the general CLI logger ({jackify_data_dir}/logs/jackify-cli.log)."""
|
||||
return self.setup_logger('jackify_cli', is_general=True)
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
@@ -722,7 +722,7 @@ class ModlistHandler:
|
||||
try:
|
||||
self.logger.info("Installing Wine components using user's preferred method...")
|
||||
self.logger.debug(f"Calling winetricks_handler.install_wine_components with wineprefix={wineprefix}, game_var={self.game_var_full}, components={components}")
|
||||
success = self.winetricks_handler.install_wine_components(wineprefix, self.game_var_full, specific_components=components)
|
||||
success = self.winetricks_handler.install_wine_components(wineprefix, self.game_var_full, specific_components=components, status_callback=status_callback)
|
||||
if success:
|
||||
self.logger.info("Wine component installation completed successfully")
|
||||
if status_callback:
|
||||
@@ -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,17 +775,49 @@ 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 5: Ensure permissions of Modlist directory
|
||||
# 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()} Setting ownership and permissions for modlist directory")
|
||||
self.logger.info("Step 5: Setting ownership and permissions for modlist directory...")
|
||||
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: Verify ownership of Modlist directory
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Verifying modlist directory ownership")
|
||||
self.logger.info("Step 5: Verifying ownership of modlist directory...")
|
||||
# Convert modlist_dir string to Path object for the method
|
||||
modlist_path_obj = Path(self.modlist_dir)
|
||||
if not self.filesystem_handler.set_ownership_and_permissions_sudo(modlist_path_obj):
|
||||
self.logger.error("Failed to set ownership/permissions for modlist directory. Configuration aborted.")
|
||||
print("Error: Failed to set permissions for the modlist directory.")
|
||||
success, error_msg = self.filesystem_handler.verify_ownership_and_permissions(modlist_path_obj)
|
||||
if not success:
|
||||
self.logger.error("Ownership verification failed for modlist directory. Configuration aborted.")
|
||||
print(f"\n{COLOR_ERROR}{error_msg}{COLOR_RESET}")
|
||||
return False # Abort on failure
|
||||
self.logger.info("Step 5: Setting ownership and permissions... Done")
|
||||
self.logger.info("Step 5: Ownership verification... Done")
|
||||
|
||||
# Step 6: Backup ModOrganizer.ini
|
||||
if status_callback:
|
||||
@@ -1596,20 +1633,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 +1659,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 +1678,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 +1717,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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -558,7 +561,8 @@ class ModlistInstallCLI:
|
||||
start_time = time.time()
|
||||
|
||||
# --- BEGIN: TEE LOGGING SETUP & LOG ROTATION ---
|
||||
log_dir = Path.home() / "Jackify" / "logs"
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
log_dir = get_jackify_logs_dir()
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
workflow_log_path = log_dir / "Modlist_Install_workflow.log"
|
||||
# Log rotation: keep last 3 logs, 1MB each (adjust as needed)
|
||||
@@ -614,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()
|
||||
@@ -644,12 +658,6 @@ class ModlistInstallCLI:
|
||||
cmd.append('--debug')
|
||||
self.logger.info("Debug mode enabled in config - passing --debug flag to jackify-engine")
|
||||
|
||||
# Check GPU setting and add --no-gpu flag if disabled
|
||||
gpu_enabled = config_handler.get('enable_gpu_texture_conversion', True)
|
||||
if not gpu_enabled:
|
||||
cmd.append('--no-gpu')
|
||||
self.logger.info("GPU texture conversion disabled - passing --no-gpu flag to jackify-engine")
|
||||
|
||||
# Determine if this is a local .wabbajack file or an online modlist
|
||||
modlist_value = self.context.get('modlist_value')
|
||||
machineid = self.context.get('machineid')
|
||||
@@ -680,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}")
|
||||
@@ -769,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='')
|
||||
@@ -777,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='')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -224,7 +224,7 @@ class ProgressParser:
|
||||
speed_info = self._parse_speed_from_string(speed_str)
|
||||
if speed_info:
|
||||
operation = self._detect_operation_from_line(status_text)
|
||||
result.speed_info = (operation, speed_info)
|
||||
result.speed_info = (operation.value, speed_info)
|
||||
|
||||
# Calculate overall percentage from step progress
|
||||
if max_steps > 0:
|
||||
@@ -400,6 +400,18 @@ class ProgressParser:
|
||||
|
||||
def _extract_file_progress(self, line: str) -> Optional[FileProgress]:
|
||||
"""Extract file-level progress information."""
|
||||
# CRITICAL: Defensive checks to prevent segfault in regex engine
|
||||
# Segfaults happen in C code before Python exceptions, so we must validate input first
|
||||
if not line or not isinstance(line, str):
|
||||
return None
|
||||
# Limit line length to prevent stack overflow in regex (10KB should be more than enough)
|
||||
if len(line) > 10000:
|
||||
return None
|
||||
# Check for null bytes or other problematic characters that could corrupt regex
|
||||
if '\x00' in line:
|
||||
# Replace null bytes to prevent corruption
|
||||
line = line.replace('\x00', '')
|
||||
|
||||
# PRIORITY: Check for [FILE_PROGRESS] prefix first (new engine format)
|
||||
# Format: [FILE_PROGRESS] Downloading: filename.zip (20.0%) [3.7MB/s]
|
||||
# Updated format: [FILE_PROGRESS] (Downloading|Extracting|Installing|Converting|Completed|etc): filename.zip (20.0%) [3.7MB/s] (current/total)
|
||||
@@ -832,6 +844,11 @@ 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
|
||||
# Track total download size from all files seen during download phase
|
||||
self._download_files_seen = {} # filename -> (total_size, max_current_size)
|
||||
self._download_total_bytes = 0 # Running total of all file sizes seen
|
||||
self._download_processed_bytes = 0 # Running total of bytes processed
|
||||
|
||||
def process_line(self, line: str) -> bool:
|
||||
"""
|
||||
@@ -850,13 +867,62 @@ 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
|
||||
|
||||
# Reset download tracking when leaving download phase
|
||||
if previous_phase == InstallationPhase.DOWNLOAD:
|
||||
self._download_files_seen = {}
|
||||
self._download_total_bytes = 0
|
||||
self._download_processed_bytes = 0
|
||||
|
||||
# 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:
|
||||
@@ -907,6 +973,46 @@ class ProgressStateManager:
|
||||
self._remove_synthetic_wabbajack()
|
||||
# Mark that we have a real .wabbajack entry to prevent synthetic ones
|
||||
self._has_real_wabbajack = True
|
||||
else:
|
||||
# CRITICAL: If we get a real archive file (not .wabbajack), remove all .wabbajack entries
|
||||
# This ensures .wabbajack entries disappear as soon as archive downloads start
|
||||
from jackify.shared.progress_models import OperationType
|
||||
if parsed.file_progress.operation == OperationType.DOWNLOAD:
|
||||
self._remove_all_wabbajack_entries()
|
||||
self._has_real_wabbajack = True # Prevent re-adding
|
||||
|
||||
# Track download totals from all files seen during download phase
|
||||
# This allows us to calculate overall remaining/ETA even when engine doesn't report data_total
|
||||
from jackify.shared.progress_models import OperationType
|
||||
if self.state.phase == InstallationPhase.DOWNLOAD and parsed.file_progress.operation == OperationType.DOWNLOAD:
|
||||
filename = parsed.file_progress.filename
|
||||
total_size = parsed.file_progress.total_size or 0
|
||||
current_size = parsed.file_progress.current_size or 0
|
||||
|
||||
# Track this file's max size and current progress
|
||||
if filename not in self._download_files_seen:
|
||||
# New file - add its total size to our running total
|
||||
if total_size > 0:
|
||||
self._download_total_bytes += total_size
|
||||
self._download_files_seen[filename] = (total_size, current_size)
|
||||
self._download_processed_bytes += current_size
|
||||
else:
|
||||
# Existing file - update current size and track max
|
||||
old_total, old_current = self._download_files_seen[filename]
|
||||
# If total_size increased (file size discovered), update our total
|
||||
if total_size > old_total:
|
||||
self._download_total_bytes += (total_size - old_total)
|
||||
# Update processed bytes (only count increases)
|
||||
if current_size > old_current:
|
||||
self._download_processed_bytes += (current_size - old_current)
|
||||
self._download_files_seen[filename] = (max(old_total, total_size), current_size)
|
||||
|
||||
# If engine didn't provide data_total, use our aggregated total
|
||||
if self.state.data_total == 0 and self._download_total_bytes > 0:
|
||||
self.state.data_total = self._download_total_bytes
|
||||
self.state.data_processed = self._download_processed_bytes
|
||||
updated = True
|
||||
|
||||
self._augment_file_metrics(parsed.file_progress)
|
||||
# Don't add files that are already at 100% unless they're being updated
|
||||
# This prevents re-adding completed files
|
||||
@@ -934,6 +1040,21 @@ class ProgressStateManager:
|
||||
self.state.add_file(parsed.file_progress)
|
||||
updated = True
|
||||
elif parsed.data_info:
|
||||
# CRITICAL: Remove .wabbajack entries as soon as archive download phase starts
|
||||
# Check if we're in "Downloading Mod Archives" phase or have real archive files downloading
|
||||
phase_name_lower = (parsed.phase_name or "").lower()
|
||||
message_lower = (parsed.message or "").lower()
|
||||
is_archive_phase = (
|
||||
'mod archives' in phase_name_lower or
|
||||
'downloading mod archives' in message_lower or
|
||||
(parsed.phase == InstallationPhase.DOWNLOAD and self._has_real_download_activity())
|
||||
)
|
||||
|
||||
if is_archive_phase:
|
||||
# Archive download phase has started - remove all .wabbajack entries immediately
|
||||
self._remove_all_wabbajack_entries()
|
||||
self._has_real_wabbajack = True # Prevent re-adding
|
||||
|
||||
# Only create synthetic .wabbajack entry if we don't already have a real one
|
||||
if not getattr(self, '_has_real_wabbajack', False):
|
||||
if self._maybe_add_wabbajack_progress(parsed):
|
||||
@@ -945,6 +1066,22 @@ class ProgressStateManager:
|
||||
parsed.completed_filename = None
|
||||
|
||||
if parsed.completed_filename:
|
||||
# Track completed files in download totals
|
||||
if self.state.phase == InstallationPhase.DOWNLOAD:
|
||||
filename = parsed.completed_filename
|
||||
# If we were tracking this file, mark it as complete (100% of total)
|
||||
if filename in self._download_files_seen:
|
||||
old_total, old_current = self._download_files_seen[filename]
|
||||
# Ensure processed bytes equals total for completed file
|
||||
if old_current < old_total:
|
||||
self._download_processed_bytes += (old_total - old_current)
|
||||
self._download_files_seen[filename] = (old_total, old_total)
|
||||
# Update state if needed
|
||||
if self.state.data_total == 0 and self._download_total_bytes > 0:
|
||||
self.state.data_total = self._download_total_bytes
|
||||
self.state.data_processed = self._download_processed_bytes
|
||||
updated = True
|
||||
|
||||
# Try to find existing file in the list
|
||||
found_existing = False
|
||||
for file_prog in self.state.active_files:
|
||||
@@ -1164,4 +1301,19 @@ class ProgressStateManager:
|
||||
remaining.append(fp)
|
||||
if removed:
|
||||
self.state.active_files = remaining
|
||||
|
||||
def _remove_all_wabbajack_entries(self):
|
||||
"""Remove ALL .wabbajack entries (synthetic and real) when archive download phase starts."""
|
||||
remaining = []
|
||||
removed = False
|
||||
for fp in self.state.active_files:
|
||||
if fp.filename.lower().endswith('.wabbajack') or 'wabbajack' in fp.filename.lower():
|
||||
removed = True
|
||||
self._file_history.pop(fp.filename, None)
|
||||
continue
|
||||
remaining.append(fp)
|
||||
if removed:
|
||||
self.state.active_files = remaining
|
||||
# Also clear the wabbajack entry name to prevent re-adding
|
||||
self._wabbajack_entry_name = None
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ class ProtontricksHandler:
|
||||
|
||||
def __init__(self, steamdeck: bool, logger=None):
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.which_protontricks = None # 'flatpak' or 'native'
|
||||
self.which_protontricks = None # 'flatpak', 'native', or 'bundled'
|
||||
self.flatpak_install_type = None # 'user' or 'system' (for flatpak installations)
|
||||
self.protontricks_version = None
|
||||
self.protontricks_path = None
|
||||
self.steamdeck = steamdeck # Store steamdeck status
|
||||
@@ -209,19 +210,36 @@ class ProtontricksHandler:
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading protontricks executable: {e}")
|
||||
|
||||
# Check if flatpak protontricks is installed
|
||||
# Check if flatpak protontricks is installed (check both user and system)
|
||||
try:
|
||||
env = self._get_clean_subprocess_env()
|
||||
result = subprocess.run(
|
||||
["flatpak", "list"],
|
||||
|
||||
# Check user installation first
|
||||
result_user = subprocess.run(
|
||||
["flatpak", "list", "--user"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env
|
||||
)
|
||||
if result.returncode == 0 and "com.github.Matoking.protontricks" in result.stdout:
|
||||
logger.info("Flatpak Protontricks is installed")
|
||||
if result_user.returncode == 0 and "com.github.Matoking.protontricks" in result_user.stdout:
|
||||
logger.info("Flatpak Protontricks is installed (user-level)")
|
||||
self.which_protontricks = 'flatpak'
|
||||
self.flatpak_install_type = 'user'
|
||||
return True
|
||||
|
||||
# Check system installation
|
||||
result_system = subprocess.run(
|
||||
["flatpak", "list", "--system"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env
|
||||
)
|
||||
if result_system.returncode == 0 and "com.github.Matoking.protontricks" in result_system.stdout:
|
||||
logger.info("Flatpak Protontricks is installed (system-level)")
|
||||
self.which_protontricks = 'flatpak'
|
||||
self.flatpak_install_type = 'system'
|
||||
return True
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.warning("'flatpak' command not found. Cannot check for Flatpak Protontricks.")
|
||||
except Exception as e:
|
||||
@@ -230,7 +248,46 @@ class ProtontricksHandler:
|
||||
# Not found
|
||||
logger.warning("Protontricks not found (native or flatpak).")
|
||||
return False
|
||||
|
||||
|
||||
def _get_flatpak_run_args(self) -> List[str]:
|
||||
"""
|
||||
Get the correct flatpak run arguments based on installation type.
|
||||
Returns list starting with ['flatpak', 'run', '--user'|'--system', ...]
|
||||
"""
|
||||
base_args = ["flatpak", "run"]
|
||||
|
||||
if self.flatpak_install_type == 'user':
|
||||
base_args.append("--user")
|
||||
elif self.flatpak_install_type == 'system':
|
||||
base_args.append("--system")
|
||||
# If flatpak_install_type is None, don't add flag (shouldn't happen in normal flow)
|
||||
|
||||
return base_args
|
||||
|
||||
def _get_flatpak_alias_string(self, command=None) -> str:
|
||||
"""
|
||||
Get the correct flatpak alias string based on installation type.
|
||||
Args:
|
||||
command: Optional command override (e.g., 'protontricks-launch').
|
||||
If None, returns base protontricks alias.
|
||||
Returns:
|
||||
String like 'flatpak run --user com.github.Matoking.protontricks'
|
||||
"""
|
||||
flag = f"--{self.flatpak_install_type}" if self.flatpak_install_type else ""
|
||||
|
||||
if command:
|
||||
# For commands like protontricks-launch
|
||||
if flag:
|
||||
return f"flatpak run {flag} --command={command} com.github.Matoking.protontricks"
|
||||
else:
|
||||
return f"flatpak run --command={command} com.github.Matoking.protontricks"
|
||||
else:
|
||||
# Base protontricks command
|
||||
if flag:
|
||||
return f"flatpak run {flag} com.github.Matoking.protontricks"
|
||||
else:
|
||||
return f"flatpak run com.github.Matoking.protontricks"
|
||||
|
||||
def check_protontricks_version(self):
|
||||
"""
|
||||
Check if the protontricks version is sufficient
|
||||
@@ -238,7 +295,7 @@ class ProtontricksHandler:
|
||||
"""
|
||||
try:
|
||||
if self.which_protontricks == 'flatpak':
|
||||
cmd = ["flatpak", "run", "com.github.Matoking.protontricks", "-V"]
|
||||
cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks", "-V"]
|
||||
else:
|
||||
cmd = ["protontricks", "-V"]
|
||||
|
||||
@@ -296,7 +353,7 @@ class ProtontricksHandler:
|
||||
cmd = [python_exe, "-m", "protontricks.cli.main"]
|
||||
cmd.extend([str(a) for a in args])
|
||||
elif self.which_protontricks == 'flatpak':
|
||||
cmd = ["flatpak", "run", "com.github.Matoking.protontricks"]
|
||||
cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks"]
|
||||
cmd.extend(args)
|
||||
else: # native
|
||||
cmd = ["protontricks"]
|
||||
@@ -396,13 +453,56 @@ class ProtontricksHandler:
|
||||
return True
|
||||
|
||||
logger.info("Setting Protontricks permissions...")
|
||||
# Bundled-runtime fix: Use cleaned environment
|
||||
env = self._get_clean_subprocess_env()
|
||||
|
||||
permissions_set = []
|
||||
permissions_failed = []
|
||||
|
||||
try:
|
||||
# Bundled-runtime fix: Use cleaned environment
|
||||
env = self._get_clean_subprocess_env()
|
||||
# 1. Set permission for modlist directory (required for wine component installation)
|
||||
logger.debug(f"Setting permission for modlist directory: {modlist_dir}")
|
||||
try:
|
||||
subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks",
|
||||
f"--filesystem={modlist_dir}"], check=True, env=env, capture_output=True)
|
||||
permissions_set.append(f"modlist directory: {modlist_dir}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
permissions_failed.append(f"modlist directory: {modlist_dir} ({e})")
|
||||
logger.warning(f"Failed to set permission for modlist directory: {e}")
|
||||
|
||||
subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks",
|
||||
f"--filesystem={modlist_dir}"], check=True, env=env)
|
||||
# 2. Set permission for main Steam directory (required for accessing compatdata, config, etc.)
|
||||
steam_dir = self._get_steam_dir_from_libraryfolders()
|
||||
if steam_dir and steam_dir.exists():
|
||||
logger.info(f"Setting permission for Steam directory: {steam_dir}")
|
||||
logger.debug("This allows protontricks to access Steam compatdata, config, and steamapps directories")
|
||||
try:
|
||||
subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks",
|
||||
f"--filesystem={steam_dir}"], check=True, env=env, capture_output=True)
|
||||
permissions_set.append(f"Steam directory: {steam_dir}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
permissions_failed.append(f"Steam directory: {steam_dir} ({e})")
|
||||
logger.warning(f"Failed to set permission for Steam directory: {e}")
|
||||
else:
|
||||
logger.warning("Could not determine Steam directory - protontricks may not have access to Steam directories")
|
||||
|
||||
# 3. Set permissions for all additional Steam library folders (compatdata can be in any library)
|
||||
from ..handlers.path_handler import PathHandler
|
||||
all_library_paths = PathHandler.get_all_steam_library_paths()
|
||||
for lib_path in all_library_paths:
|
||||
# Skip if this is the main Steam directory (already set above)
|
||||
if steam_dir and lib_path.resolve() == steam_dir.resolve():
|
||||
continue
|
||||
if lib_path.exists():
|
||||
logger.debug(f"Setting permission for Steam library folder: {lib_path}")
|
||||
try:
|
||||
subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks",
|
||||
f"--filesystem={lib_path}"], check=True, env=env, capture_output=True)
|
||||
permissions_set.append(f"Steam library: {lib_path}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
permissions_failed.append(f"Steam library: {lib_path} ({e})")
|
||||
logger.warning(f"Failed to set permission for Steam library folder {lib_path}: {e}")
|
||||
|
||||
# 4. Set SD card permissions (Steam Deck only)
|
||||
if steamdeck:
|
||||
logger.warn("Checking for SDCard and setting permissions appropriately...")
|
||||
# Find sdcard path
|
||||
@@ -411,15 +511,40 @@ class ProtontricksHandler:
|
||||
if "/run/media" in line:
|
||||
sdcard_path = line.split()[-1]
|
||||
logger.debug(f"SDCard path: {sdcard_path}")
|
||||
subprocess.run(["flatpak", "override", "--user", f"--filesystem={sdcard_path}",
|
||||
"com.github.Matoking.protontricks"], check=True, env=env)
|
||||
try:
|
||||
subprocess.run(["flatpak", "override", "--user", f"--filesystem={sdcard_path}",
|
||||
"com.github.Matoking.protontricks"], check=True, env=env, capture_output=True)
|
||||
permissions_set.append(f"SD card: {sdcard_path}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
permissions_failed.append(f"SD card: {sdcard_path} ({e})")
|
||||
logger.warning(f"Failed to set permission for SD card {sdcard_path}: {e}")
|
||||
# Add standard Steam Deck SD card path as fallback
|
||||
subprocess.run(["flatpak", "override", "--user", "--filesystem=/run/media/mmcblk0p1",
|
||||
"com.github.Matoking.protontricks"], check=True, env=env)
|
||||
logger.debug("Permissions set successfully")
|
||||
return True
|
||||
try:
|
||||
subprocess.run(["flatpak", "override", "--user", "--filesystem=/run/media/mmcblk0p1",
|
||||
"com.github.Matoking.protontricks"], check=True, env=env, capture_output=True)
|
||||
permissions_set.append("SD card: /run/media/mmcblk0p1")
|
||||
except subprocess.CalledProcessError as e:
|
||||
# This is expected to fail if the path doesn't exist, so only log at debug level
|
||||
logger.debug(f"Could not set permission for fallback SD card path (may not exist): {e}")
|
||||
|
||||
# Report results
|
||||
if permissions_set:
|
||||
logger.info(f"Successfully set {len(permissions_set)} permission(s) for protontricks")
|
||||
logger.debug(f"Permissions set: {', '.join(permissions_set)}")
|
||||
if permissions_failed:
|
||||
logger.warning(f"Failed to set {len(permissions_failed)} permission(s)")
|
||||
logger.debug(f"Failed permissions: {', '.join(permissions_failed)}")
|
||||
|
||||
# Return True if at least modlist directory permission was set (critical)
|
||||
if any("modlist directory" in p for p in permissions_set):
|
||||
logger.info("Protontricks permissions configured (at least modlist directory access granted)")
|
||||
return True
|
||||
else:
|
||||
logger.error("Failed to set critical modlist directory permission")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set Protontricks permissions: {e}")
|
||||
logger.error(f"Unexpected error while setting Protontricks permissions: {e}")
|
||||
return False
|
||||
|
||||
def create_protontricks_alias(self):
|
||||
@@ -443,15 +568,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 +627,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 +723,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 +799,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 +827,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 +898,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,11 +916,64 @@ 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.
|
||||
If specific_components is None, use the default set (fontsmooth=rgb, xact, xact_x64, vcrun2022).
|
||||
"""
|
||||
self.logger.info("=" * 80)
|
||||
self.logger.info("USING PROTONTRICKS")
|
||||
self.logger.info("=" * 80)
|
||||
env = self._get_clean_subprocess_env()
|
||||
env["WINEDEBUG"] = "-all"
|
||||
|
||||
@@ -820,6 +1012,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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,8 @@ from .subprocess_utils import get_clean_subprocess_env
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Define default TTW_Linux_Installer paths
|
||||
JACKIFY_BASE_DIR = Path.home() / "Jackify"
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
JACKIFY_BASE_DIR = get_jackify_data_dir()
|
||||
DEFAULT_TTW_INSTALLER_DIR = JACKIFY_BASE_DIR / "TTW_Linux_Installer"
|
||||
TTW_INSTALLER_EXECUTABLE_NAME = "ttw_linux_gui" # Same executable, runs in CLI mode with args
|
||||
|
||||
@@ -292,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,
|
||||
@@ -400,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,
|
||||
@@ -551,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,
|
||||
|
||||
@@ -300,7 +300,7 @@ class ValidationHandler:
|
||||
def looks_like_modlist_dir(self, path: Path) -> bool:
|
||||
"""Return True if the directory contains files/folders typical of a modlist install."""
|
||||
expected = [
|
||||
'ModOrganizer.exe', 'profiles', 'mods', 'downloads', '.wabbajack', '.jackify_modlist_marker', 'ModOrganizer.ini'
|
||||
'ModOrganizer.exe', 'profiles', 'mods', '.wabbajack', '.jackify_modlist_marker', 'ModOrganizer.ini'
|
||||
]
|
||||
for item in expected:
|
||||
if (path / item).exists():
|
||||
|
||||
@@ -1196,7 +1196,8 @@ class InstallWabbajackHandler:
|
||||
"""Displays the final success message and next steps."""
|
||||
# Basic log file path (assuming standard location)
|
||||
# TODO: Get log file path more reliably if needed
|
||||
log_path = Path.home() / "Jackify" / "logs" / "jackify-cli.log"
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
log_path = get_jackify_logs_dir() / "jackify-cli.log"
|
||||
|
||||
print("\n───────────────────────────────────────────────────────────────────")
|
||||
print(f"{COLOR_INFO}Wabbajack Installation Completed Successfully!{COLOR_RESET}")
|
||||
|
||||
601
jackify/backend/handlers/wabbajack_installer_handler.py
Normal file
601
jackify/backend/handlers/wabbajack_installer_handler.py
Normal file
@@ -0,0 +1,601 @@
|
||||
"""
|
||||
Wabbajack Installer Handler
|
||||
|
||||
Automated Wabbajack.exe installation and configuration via Proton.
|
||||
Self-contained implementation inspired by Wabbajack-Proton-AuCu (MIT).
|
||||
|
||||
This handler provides:
|
||||
- Automatic Wabbajack.exe download
|
||||
- Steam shortcuts.vdf manipulation
|
||||
- WebView2 installation
|
||||
- Win7 registry configuration
|
||||
- Optional Heroic GOG game detection
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Tuple
|
||||
|
||||
try:
|
||||
import vdf
|
||||
except ImportError:
|
||||
vdf = None
|
||||
|
||||
|
||||
class WabbajackInstallerHandler:
|
||||
"""Handles automated Wabbajack installation via Proton"""
|
||||
|
||||
# Download URLs
|
||||
WABBAJACK_URL = "https://github.com/wabbajack-tools/wabbajack/releases/latest/download/Wabbajack.exe"
|
||||
WEBVIEW2_URL = "https://files.omnigaming.org/MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe"
|
||||
|
||||
# Minimal Win7 registry settings for Wabbajack compatibility
|
||||
WIN7_REGISTRY = """REGEDIT4
|
||||
|
||||
[HKEY_LOCAL_MACHINE\\Software\\Microsoft\\Windows NT\\CurrentVersion]
|
||||
"ProductName"="Microsoft Windows 7"
|
||||
"CSDVersion"="Service Pack 1"
|
||||
"CurrentBuild"="7601"
|
||||
"CurrentBuildNumber"="7601"
|
||||
"CurrentVersion"="6.1"
|
||||
|
||||
[HKEY_LOCAL_MACHINE\\System\\CurrentControlSet\\Control\\Windows]
|
||||
"CSDVersion"=dword:00000100
|
||||
|
||||
[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\Wabbajack.exe\\X11 Driver]
|
||||
"Decorated"="N"
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def calculate_app_id(self, exe_path: str, app_name: str) -> int:
|
||||
"""
|
||||
Calculate Steam AppID using CRC32 algorithm.
|
||||
|
||||
Args:
|
||||
exe_path: Path to executable (must be quoted)
|
||||
app_name: Application name
|
||||
|
||||
Returns:
|
||||
AppID (31-bit to fit signed 32-bit integer range for VDF binary format)
|
||||
"""
|
||||
input_str = f"{exe_path}{app_name}"
|
||||
crc = zlib.crc32(input_str.encode()) & 0x7FFFFFFF # Use 31 bits for signed int
|
||||
return crc
|
||||
|
||||
def find_steam_userdata_path(self) -> Optional[Path]:
|
||||
"""
|
||||
Find most recently used Steam userdata directory.
|
||||
|
||||
Returns:
|
||||
Path to userdata/<userid> or None if not found
|
||||
"""
|
||||
home = Path.home()
|
||||
steam_paths = [
|
||||
home / ".steam/steam",
|
||||
home / ".local/share/Steam",
|
||||
home / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
|
||||
]
|
||||
|
||||
for steam_path in steam_paths:
|
||||
userdata = steam_path / "userdata"
|
||||
if not userdata.exists():
|
||||
continue
|
||||
|
||||
# Find most recently modified numeric user directory
|
||||
user_dirs = []
|
||||
for entry in userdata.iterdir():
|
||||
if entry.is_dir() and entry.name.isdigit():
|
||||
user_dirs.append(entry)
|
||||
|
||||
if user_dirs:
|
||||
# Sort by modification time (most recent first)
|
||||
user_dirs.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
self.logger.info(f"Found Steam userdata: {user_dirs[0]}")
|
||||
return user_dirs[0]
|
||||
|
||||
return None
|
||||
|
||||
def get_shortcuts_vdf_path(self) -> Optional[Path]:
|
||||
"""Get path to shortcuts.vdf file"""
|
||||
userdata = self.find_steam_userdata_path()
|
||||
if userdata:
|
||||
return userdata / "config/shortcuts.vdf"
|
||||
return None
|
||||
|
||||
def add_to_steam_shortcuts(self, exe_path: Path) -> int:
|
||||
"""
|
||||
Add Wabbajack to Steam shortcuts.vdf and return calculated AppID.
|
||||
|
||||
Args:
|
||||
exe_path: Path to Wabbajack.exe
|
||||
|
||||
Returns:
|
||||
Calculated AppID
|
||||
|
||||
Raises:
|
||||
RuntimeError: If vdf library not available or shortcuts.vdf not found
|
||||
"""
|
||||
if vdf is None:
|
||||
raise RuntimeError("vdf library not installed. Install with: pip install vdf")
|
||||
|
||||
shortcuts_path = self.get_shortcuts_vdf_path()
|
||||
if not shortcuts_path:
|
||||
raise RuntimeError("Could not find Steam shortcuts.vdf path")
|
||||
|
||||
self.logger.info(f"Shortcuts.vdf path: {shortcuts_path}")
|
||||
|
||||
# Read existing shortcuts or create new
|
||||
if shortcuts_path.exists():
|
||||
with open(shortcuts_path, 'rb') as f:
|
||||
shortcuts = vdf.binary_load(f)
|
||||
else:
|
||||
shortcuts = {'shortcuts': {}}
|
||||
# Ensure parent directory exists
|
||||
shortcuts_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Calculate AppID
|
||||
exe_str = f'"{str(exe_path)}"'
|
||||
app_id = self.calculate_app_id(exe_str, "Wabbajack")
|
||||
|
||||
self.logger.info(f"Calculated AppID: {app_id}")
|
||||
|
||||
# Create shortcut entry
|
||||
idx = str(len(shortcuts.get('shortcuts', {})))
|
||||
shortcuts.setdefault('shortcuts', {})[idx] = {
|
||||
'appid': app_id,
|
||||
'AppName': 'Wabbajack',
|
||||
'Exe': exe_str,
|
||||
'StartDir': f'"{str(exe_path.parent)}"',
|
||||
'icon': str(exe_path),
|
||||
'ShortcutPath': '',
|
||||
'LaunchOptions': '',
|
||||
'IsHidden': 0,
|
||||
'AllowDesktopConfig': 1,
|
||||
'AllowOverlay': 1,
|
||||
'OpenVR': 0,
|
||||
'Devkit': 0,
|
||||
'DevkitGameID': '',
|
||||
'DevkitOverrideAppID': 0,
|
||||
'LastPlayTime': 0,
|
||||
'FlatpakAppID': '',
|
||||
'tags': {}
|
||||
}
|
||||
|
||||
# Write back (binary format)
|
||||
with open(shortcuts_path, 'wb') as f:
|
||||
vdf.binary_dump(shortcuts, f)
|
||||
|
||||
self.logger.info(f"Added Wabbajack to Steam shortcuts with AppID {app_id}")
|
||||
return app_id
|
||||
|
||||
def create_dotnet_cache(self, install_folder: Path):
|
||||
"""
|
||||
Create .NET bundle extract cache directory.
|
||||
|
||||
Wabbajack requires: <install_path>/<home_path>/.cache/dotnet_bundle_extract
|
||||
|
||||
Args:
|
||||
install_folder: Wabbajack installation directory
|
||||
"""
|
||||
home = Path.home()
|
||||
# Strip leading slash to make it relative
|
||||
home_relative = str(home).lstrip('/')
|
||||
cache_dir = install_folder / home_relative / '.cache/dotnet_bundle_extract'
|
||||
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.logger.info(f"Created dotnet cache: {cache_dir}")
|
||||
|
||||
def download_file(self, url: str, dest: Path, description: str = "file") -> None:
|
||||
"""
|
||||
Download file with progress logging.
|
||||
|
||||
Args:
|
||||
url: Download URL
|
||||
dest: Destination path
|
||||
description: Description for logging
|
||||
|
||||
Raises:
|
||||
RuntimeError: If download fails
|
||||
"""
|
||||
self.logger.info(f"Downloading {description} from {url}")
|
||||
|
||||
try:
|
||||
# Ensure parent directory exists
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download with user agent
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={'User-Agent': 'Jackify-WabbajackInstaller'}
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(request) as response:
|
||||
with open(dest, 'wb') as f:
|
||||
shutil.copyfileobj(response, f)
|
||||
|
||||
self.logger.info(f"Downloaded {description} to {dest}")
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to download {description}: {e}")
|
||||
|
||||
def download_wabbajack(self, install_folder: Path) -> Path:
|
||||
"""
|
||||
Download Wabbajack.exe to installation folder.
|
||||
|
||||
Args:
|
||||
install_folder: Installation directory
|
||||
|
||||
Returns:
|
||||
Path to downloaded Wabbajack.exe
|
||||
"""
|
||||
install_folder.mkdir(parents=True, exist_ok=True)
|
||||
wabbajack_exe = install_folder / "Wabbajack.exe"
|
||||
|
||||
# Skip if already exists
|
||||
if wabbajack_exe.exists():
|
||||
self.logger.info(f"Wabbajack.exe already exists at {wabbajack_exe}")
|
||||
return wabbajack_exe
|
||||
|
||||
self.download_file(self.WABBAJACK_URL, wabbajack_exe, "Wabbajack.exe")
|
||||
return wabbajack_exe
|
||||
|
||||
def find_proton_experimental(self) -> Optional[Path]:
|
||||
"""
|
||||
Find Proton Experimental installation path.
|
||||
|
||||
Returns:
|
||||
Path to Proton Experimental directory or None
|
||||
"""
|
||||
home = Path.home()
|
||||
steam_paths = [
|
||||
home / ".steam/steam",
|
||||
home / ".local/share/Steam",
|
||||
home / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
|
||||
]
|
||||
|
||||
for steam_path in steam_paths:
|
||||
proton_path = steam_path / "steamapps/common/Proton - Experimental"
|
||||
if proton_path.exists():
|
||||
self.logger.info(f"Found Proton Experimental: {proton_path}")
|
||||
return proton_path
|
||||
|
||||
return None
|
||||
|
||||
def get_compat_data_path(self, app_id: int) -> Optional[Path]:
|
||||
"""Get compatdata path for AppID"""
|
||||
home = Path.home()
|
||||
steam_paths = [
|
||||
home / ".steam/steam",
|
||||
home / ".local/share/Steam",
|
||||
home / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
|
||||
]
|
||||
|
||||
for steam_path in steam_paths:
|
||||
compat_path = steam_path / f"steamapps/compatdata/{app_id}"
|
||||
if compat_path.parent.exists():
|
||||
# Parent exists, so this is valid location even if prefix doesn't exist yet
|
||||
return compat_path
|
||||
|
||||
return None
|
||||
|
||||
def init_wine_prefix(self, app_id: int) -> Path:
|
||||
"""
|
||||
Initialize Wine prefix using Proton.
|
||||
|
||||
Args:
|
||||
app_id: Steam AppID
|
||||
|
||||
Returns:
|
||||
Path to created prefix
|
||||
|
||||
Raises:
|
||||
RuntimeError: If prefix creation fails
|
||||
"""
|
||||
proton_path = self.find_proton_experimental()
|
||||
if not proton_path:
|
||||
raise RuntimeError("Proton Experimental not found. Please install it from Steam.")
|
||||
|
||||
compat_data = self.get_compat_data_path(app_id)
|
||||
if not compat_data:
|
||||
raise RuntimeError("Could not determine compatdata path")
|
||||
|
||||
prefix_path = compat_data / "pfx"
|
||||
|
||||
# Create compat data directory
|
||||
compat_data.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Run wineboot to initialize prefix
|
||||
proton_bin = proton_path / "proton"
|
||||
env = os.environ.copy()
|
||||
env['STEAM_COMPAT_DATA_PATH'] = str(compat_data)
|
||||
env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(compat_data.parent.parent.parent)
|
||||
|
||||
self.logger.info(f"Initializing Wine prefix for AppID {app_id}...")
|
||||
result = subprocess.run(
|
||||
[str(proton_bin), 'run', 'wineboot'],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Failed to initialize Wine prefix: {result.stderr}")
|
||||
|
||||
self.logger.info(f"Prefix created: {prefix_path}")
|
||||
return prefix_path
|
||||
|
||||
def run_in_prefix(self, app_id: int, exe_path: Path, args: List[str] = None) -> None:
|
||||
"""
|
||||
Run executable in Wine prefix using Proton.
|
||||
|
||||
Args:
|
||||
app_id: Steam AppID
|
||||
exe_path: Path to executable
|
||||
args: Optional command line arguments
|
||||
|
||||
Raises:
|
||||
RuntimeError: If execution fails
|
||||
"""
|
||||
proton_path = self.find_proton_experimental()
|
||||
if not proton_path:
|
||||
raise RuntimeError("Proton Experimental not found")
|
||||
|
||||
compat_data = self.get_compat_data_path(app_id)
|
||||
if not compat_data:
|
||||
raise RuntimeError("Could not determine compatdata path")
|
||||
|
||||
proton_bin = proton_path / "proton"
|
||||
cmd = [str(proton_bin), 'run', str(exe_path)]
|
||||
if args:
|
||||
cmd.extend(args)
|
||||
|
||||
env = os.environ.copy()
|
||||
env['STEAM_COMPAT_DATA_PATH'] = str(compat_data)
|
||||
env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(compat_data.parent.parent.parent)
|
||||
|
||||
self.logger.info(f"Running {exe_path.name} in prefix...")
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_msg = f"Failed to run {exe_path.name} (exit code {result.returncode})"
|
||||
if result.stderr:
|
||||
error_msg += f"\nStderr: {result.stderr}"
|
||||
if result.stdout:
|
||||
error_msg += f"\nStdout: {result.stdout}"
|
||||
self.logger.error(error_msg)
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
def apply_registry(self, app_id: int, reg_content: str) -> None:
|
||||
"""
|
||||
Apply registry content to Wine prefix.
|
||||
|
||||
Args:
|
||||
app_id: Steam AppID
|
||||
reg_content: Registry file content
|
||||
|
||||
Raises:
|
||||
RuntimeError: If registry application fails
|
||||
"""
|
||||
proton_path = self.find_proton_experimental()
|
||||
if not proton_path:
|
||||
raise RuntimeError("Proton Experimental not found")
|
||||
|
||||
compat_data = self.get_compat_data_path(app_id)
|
||||
if not compat_data:
|
||||
raise RuntimeError("Could not determine compatdata path")
|
||||
|
||||
prefix_path = compat_data / "pfx"
|
||||
if not prefix_path.exists():
|
||||
raise RuntimeError(f"Prefix not found: {prefix_path}")
|
||||
|
||||
# Write registry content to temp file
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.reg', delete=False) as f:
|
||||
f.write(reg_content)
|
||||
temp_reg = Path(f.name)
|
||||
|
||||
try:
|
||||
# Use Proton's wine directly
|
||||
wine_bin = proton_path / "files/bin/wine64"
|
||||
|
||||
self.logger.info("Applying registry settings...")
|
||||
env = os.environ.copy()
|
||||
env['WINEPREFIX'] = str(prefix_path)
|
||||
result = subprocess.run(
|
||||
[str(wine_bin), 'regedit', str(temp_reg)],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Failed to apply registry: {result.stderr}")
|
||||
|
||||
self.logger.info("Registry settings applied")
|
||||
|
||||
finally:
|
||||
# Cleanup temp file
|
||||
if temp_reg.exists():
|
||||
temp_reg.unlink()
|
||||
|
||||
def install_webview2(self, app_id: int, install_folder: Path) -> None:
|
||||
"""
|
||||
Download and install WebView2 runtime.
|
||||
|
||||
Args:
|
||||
app_id: Steam AppID
|
||||
install_folder: Directory to download installer to
|
||||
|
||||
Raises:
|
||||
RuntimeError: If installation fails
|
||||
"""
|
||||
webview_installer = install_folder / "webview2_installer.exe"
|
||||
|
||||
# Download installer
|
||||
self.download_file(self.WEBVIEW2_URL, webview_installer, "WebView2 installer")
|
||||
|
||||
try:
|
||||
# Run installer with silent flags
|
||||
self.logger.info("Installing WebView2 (this may take a minute)...")
|
||||
self.logger.info(f"WebView2 installer path: {webview_installer}")
|
||||
self.logger.info(f"AppID: {app_id}")
|
||||
try:
|
||||
self.run_in_prefix(app_id, webview_installer, ["/silent", "/install"])
|
||||
self.logger.info("WebView2 installed successfully")
|
||||
except RuntimeError as e:
|
||||
self.logger.error(f"WebView2 installation failed: {e}")
|
||||
# Re-raise to let caller handle it
|
||||
raise
|
||||
|
||||
finally:
|
||||
# Cleanup installer
|
||||
if webview_installer.exists():
|
||||
try:
|
||||
webview_installer.unlink()
|
||||
self.logger.debug("Cleaned up WebView2 installer")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to cleanup WebView2 installer: {e}")
|
||||
|
||||
def apply_win7_registry(self, app_id: int) -> None:
|
||||
"""
|
||||
Apply Windows 7 registry settings.
|
||||
|
||||
Args:
|
||||
app_id: Steam AppID
|
||||
|
||||
Raises:
|
||||
RuntimeError: If registry application fails
|
||||
"""
|
||||
self.apply_registry(app_id, self.WIN7_REGISTRY)
|
||||
|
||||
def detect_heroic_gog_games(self) -> List[Dict]:
|
||||
"""
|
||||
Detect GOG games installed via Heroic Games Launcher.
|
||||
|
||||
Returns:
|
||||
List of dicts with keys: app_name, title, install_path, build_id
|
||||
"""
|
||||
heroic_paths = [
|
||||
Path.home() / ".config/heroic",
|
||||
Path.home() / ".var/app/com.heroicgameslauncher.hgl/config/heroic"
|
||||
]
|
||||
|
||||
for heroic_path in heroic_paths:
|
||||
if not heroic_path.exists():
|
||||
continue
|
||||
|
||||
installed_json = heroic_path / "gog_store/installed.json"
|
||||
if not installed_json.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
# Read installed games
|
||||
with open(installed_json) as f:
|
||||
data = json.load(f)
|
||||
installed = data.get('installed', [])
|
||||
|
||||
# Read library for titles
|
||||
library_json = heroic_path / "store_cache/gog_library.json"
|
||||
titles = {}
|
||||
if library_json.exists():
|
||||
with open(library_json) as f:
|
||||
lib = json.load(f)
|
||||
titles = {g['app_name']: g['title'] for g in lib.get('games', [])}
|
||||
|
||||
# Build game list
|
||||
games = []
|
||||
for game in installed:
|
||||
app_name = game.get('appName')
|
||||
if not app_name:
|
||||
continue
|
||||
|
||||
games.append({
|
||||
'app_name': app_name,
|
||||
'title': titles.get(app_name, f"GOG Game {app_name}"),
|
||||
'install_path': game.get('install_path', ''),
|
||||
'build_id': game.get('buildId', '')
|
||||
})
|
||||
|
||||
if games:
|
||||
self.logger.info(f"Found {len(games)} GOG games from Heroic")
|
||||
for game in games:
|
||||
self.logger.debug(f" - {game['title']} ({game['app_name']})")
|
||||
|
||||
return games
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to read Heroic config: {e}")
|
||||
continue
|
||||
|
||||
return []
|
||||
|
||||
def generate_gog_registry(self, games: List[Dict]) -> str:
|
||||
"""
|
||||
Generate registry file content for GOG games.
|
||||
|
||||
Args:
|
||||
games: List of GOG game dicts from detect_heroic_gog_games()
|
||||
|
||||
Returns:
|
||||
Registry file content
|
||||
"""
|
||||
reg = "REGEDIT4\n\n"
|
||||
reg += "[HKEY_LOCAL_MACHINE\\Software\\GOG.com]\n\n"
|
||||
reg += "[HKEY_LOCAL_MACHINE\\Software\\GOG.com\\Games]\n\n"
|
||||
reg += "[HKEY_LOCAL_MACHINE\\Software\\WOW6432Node\\GOG.com]\n\n"
|
||||
reg += "[HKEY_LOCAL_MACHINE\\Software\\WOW6432Node\\GOG.com\\Games]\n\n"
|
||||
|
||||
for game in games:
|
||||
# Convert Linux path to Wine Z: drive
|
||||
linux_path = game['install_path']
|
||||
wine_path = f"Z:{linux_path}".replace('/', '\\\\')
|
||||
|
||||
# Add to both 32-bit and 64-bit registry locations
|
||||
for prefix in ['Software\\GOG.com\\Games', 'Software\\WOW6432Node\\GOG.com\\Games']:
|
||||
reg += f"[HKEY_LOCAL_MACHINE\\{prefix}\\{game['app_name']}]\n"
|
||||
reg += f'"path"="{wine_path}"\n'
|
||||
reg += f'"gameID"="{game["app_name"]}"\n'
|
||||
reg += f'"gameName"="{game["title"]}"\n'
|
||||
reg += f'"buildId"="{game["build_id"]}"\n'
|
||||
reg += f'"workingDir"="{wine_path}"\n\n'
|
||||
|
||||
return reg
|
||||
|
||||
def inject_gog_registry(self, app_id: int) -> int:
|
||||
"""
|
||||
Inject Heroic GOG games into Wine prefix registry.
|
||||
|
||||
Args:
|
||||
app_id: Steam AppID
|
||||
|
||||
Returns:
|
||||
Number of games injected
|
||||
"""
|
||||
games = self.detect_heroic_gog_games()
|
||||
|
||||
if not games:
|
||||
self.logger.info("No GOG games found in Heroic")
|
||||
return 0
|
||||
|
||||
reg_content = self.generate_gog_registry(games)
|
||||
|
||||
self.logger.info(f"Injecting {len(games)} GOG games into prefix...")
|
||||
self.apply_registry(app_id, reg_content)
|
||||
self.logger.info(f"Injected {len(games)} GOG games")
|
||||
return len(games)
|
||||
@@ -272,47 +272,45 @@ class WineUtils:
|
||||
@staticmethod
|
||||
def chown_chmod_modlist_dir(modlist_dir):
|
||||
"""
|
||||
Change ownership and permissions of modlist directory
|
||||
Returns True on success, False on failure
|
||||
DEPRECATED: Use FileSystemHandler.verify_ownership_and_permissions() instead.
|
||||
Verify and fix ownership/permissions for modlist directory.
|
||||
Returns True if successful, False if sudo required.
|
||||
"""
|
||||
if WineUtils.all_owned_by_user(modlist_dir):
|
||||
logger.info(f"All files in {modlist_dir} are already owned by the current user. Skipping sudo chown/chmod.")
|
||||
return True
|
||||
logger.warn("Changing Ownership and Permissions of modlist directory (may require sudo password)")
|
||||
|
||||
try:
|
||||
user = subprocess.run("whoami", shell=True, capture_output=True, text=True).stdout.strip()
|
||||
group = subprocess.run("id -gn", shell=True, capture_output=True, text=True).stdout.strip()
|
||||
|
||||
logger.debug(f"User is {user} and Group is {group}")
|
||||
|
||||
# Change ownership
|
||||
result1 = subprocess.run(
|
||||
f"sudo chown -R {user}:{group} \"{modlist_dir}\"",
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
# Change permissions
|
||||
result2 = subprocess.run(
|
||||
f"sudo chmod -R 755 \"{modlist_dir}\"",
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result1.returncode != 0 or result2.returncode != 0:
|
||||
logger.error("Failed to change ownership/permissions")
|
||||
logger.error(f"chown output: {result1.stderr}")
|
||||
logger.error(f"chmod output: {result2.stderr}")
|
||||
if not WineUtils.all_owned_by_user(modlist_dir):
|
||||
# Files not owned by us - need sudo to fix
|
||||
logger.error(f"Ownership issue detected: Some files in {modlist_dir} are not owned by the current user")
|
||||
|
||||
try:
|
||||
user = subprocess.run("whoami", shell=True, capture_output=True, text=True).stdout.strip()
|
||||
group = subprocess.run("id -gn", shell=True, capture_output=True, text=True).stdout.strip()
|
||||
|
||||
logger.error("To fix ownership issues, open a terminal and run:")
|
||||
logger.error(f" sudo chown -R {user}:{group} \"{modlist_dir}\"")
|
||||
logger.error(f" sudo chmod -R 755 \"{modlist_dir}\"")
|
||||
logger.error("After running these commands, retry the operation.")
|
||||
return False
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking ownership: {e}")
|
||||
return False
|
||||
|
||||
# Files are owned by us - try to fix permissions ourselves
|
||||
logger.info(f"Files in {modlist_dir} are owned by current user, verifying permissions...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['chmod', '-R', '755', modlist_dir],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info(f"Permissions set successfully for {modlist_dir}")
|
||||
else:
|
||||
logger.warning(f"chmod returned non-zero but continuing: {result.stderr}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error changing ownership and permissions: {e}")
|
||||
return False
|
||||
logger.warning(f"Error running chmod: {e}, continuing anyway")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def create_dxvk_file(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full):
|
||||
|
||||
@@ -9,7 +9,7 @@ import os
|
||||
import subprocess
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -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
|
||||
@@ -110,10 +125,16 @@ class WinetricksHandler:
|
||||
self.logger.error(f"Error testing winetricks: {e}")
|
||||
return False
|
||||
|
||||
def install_wine_components(self, wineprefix: str, game_var: str, specific_components: Optional[List[str]] = None) -> bool:
|
||||
def install_wine_components(self, wineprefix: str, game_var: str, specific_components: Optional[List[str]] = None, status_callback: Optional[Callable[[str], None]] = None) -> bool:
|
||||
"""
|
||||
Install the specified Wine components into the given prefix using winetricks.
|
||||
If specific_components is None, use the default set (fontsmooth=rgb, xact, xact_x64, vcrun2022).
|
||||
|
||||
Args:
|
||||
wineprefix: Path to Wine prefix
|
||||
game_var: Game name for logging
|
||||
specific_components: Optional list of specific components to install
|
||||
status_callback: Optional callback function(status_message: str) for progress updates
|
||||
"""
|
||||
if not self.is_available():
|
||||
self.logger.error("Winetricks is not available")
|
||||
@@ -143,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
|
||||
@@ -245,13 +266,156 @@ 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}")
|
||||
# CRITICAL: NEVER add bundled downloaders to PATH - they segfault on some systems
|
||||
# Let winetricks auto-detect system downloaders (aria2c > wget > curl > fetch)
|
||||
# Winetricks will automatically fall back if preferred tool isn't available
|
||||
# We verify at least one exists before proceeding
|
||||
|
||||
# Quick check: does system have at least one downloader?
|
||||
has_downloader = False
|
||||
for tool in ['aria2c', 'curl', 'wget']:
|
||||
try:
|
||||
result = subprocess.run(['which', tool], capture_output=True, timeout=2, env=os.environ.copy())
|
||||
if result.returncode == 0:
|
||||
has_downloader = True
|
||||
self.logger.info(f"System has {tool} available - winetricks will auto-select best option")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not has_downloader:
|
||||
self._handle_missing_downloader_error()
|
||||
return False
|
||||
|
||||
# Don't set WINETRICKS_DOWNLOADER - let winetricks auto-detect and fall back
|
||||
# This ensures it uses the best available tool and handles fallbacks automatically
|
||||
|
||||
# Set up bundled tools directory for winetricks
|
||||
# NEVER add bundled downloaders to PATH - they segfault on some systems
|
||||
# Only bundle non-downloader tools: cabextract, unzip, 7z, xz, sha256sum
|
||||
tools_dir = None
|
||||
bundled_tools = []
|
||||
|
||||
# Check for bundled tools and collect their directory
|
||||
# Downloaders (aria2c, wget, curl) are NEVER bundled - always use system tools
|
||||
tool_names = ['cabextract', '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)
|
||||
|
||||
# Add bundled tools to PATH (system PATH first, so system downloaders are found first)
|
||||
# NEVER add bundled downloaders - only archive/utility tools
|
||||
if tools_dir:
|
||||
# System PATH first, then bundled tools (so system downloaders are always found first)
|
||||
env['PATH'] = f"{env.get('PATH', '')}:{tools_dir}"
|
||||
bundling_msg = f"Using bundled tools directory (after system PATH): {tools_dir}"
|
||||
self.logger.info(bundling_msg)
|
||||
if status_callback:
|
||||
status_callback(bundling_msg)
|
||||
tools_msg = f"Bundled tools available: {', '.join(bundled_tools)}"
|
||||
self.logger.info(tools_msg)
|
||||
if status_callback:
|
||||
status_callback(tools_msg)
|
||||
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
|
||||
deps_check_msg = "=== Checking winetricks dependencies ==="
|
||||
self.logger.info(deps_check_msg)
|
||||
if status_callback:
|
||||
status_callback(deps_check_msg)
|
||||
missing_deps = []
|
||||
bundled_tools_list = ['aria2c', 'wget', 'unzip', '7z', 'xz', 'sha256sum', 'cabextract']
|
||||
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]
|
||||
|
||||
# Check for bundled version only for tools we bundle
|
||||
if dep_name in bundled_tools_list:
|
||||
bundled_tool = None
|
||||
for cmd in commands:
|
||||
bundled_tool = self._get_bundled_tool(cmd, fallback_to_system=False)
|
||||
if bundled_tool:
|
||||
dep_msg = f" ✓ {dep_name}: {bundled_tool} (bundled)"
|
||||
self.logger.info(dep_msg)
|
||||
if status_callback:
|
||||
status_callback(dep_msg)
|
||||
found = True
|
||||
break
|
||||
|
||||
# Check system PATH if not found bundled
|
||||
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()
|
||||
dep_msg = f" ✓ {dep_name}: {cmd_path} (system)"
|
||||
self.logger.info(dep_msg)
|
||||
if status_callback:
|
||||
status_callback(dep_msg)
|
||||
found = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not found:
|
||||
missing_deps.append(dep_name)
|
||||
if dep_name in bundled_tools_list:
|
||||
self.logger.warning(f" ✗ {dep_name}: NOT FOUND (neither bundled nor system)")
|
||||
else:
|
||||
self.logger.warning(f" ✗ {dep_name}: NOT FOUND (system only - not bundled)")
|
||||
|
||||
if missing_deps:
|
||||
# Separate critical vs optional dependencies
|
||||
download_deps = [d for d in missing_deps if d in ['wget', 'curl', 'aria2c']]
|
||||
critical_deps = [d for d in missing_deps if d not in ['aria2c']]
|
||||
optional_deps = [d for d in missing_deps if d in ['aria2c']]
|
||||
|
||||
if critical_deps:
|
||||
self.logger.warning(f"Missing critical winetricks dependencies: {', '.join(critical_deps)}")
|
||||
self.logger.warning("Winetricks may fail if these are required for component installation")
|
||||
|
||||
if optional_deps:
|
||||
self.logger.info(f"Optional dependencies not found (will use alternatives): {', '.join(optional_deps)}")
|
||||
self.logger.info("aria2c is optional - winetricks will use wget/curl if available")
|
||||
|
||||
# Special warning if ALL downloaders are missing
|
||||
all_downloaders = {'wget', 'curl', 'aria2c'}
|
||||
missing_downloaders = set(download_deps)
|
||||
if missing_downloaders == all_downloaders:
|
||||
self.logger.error("=" * 80)
|
||||
self.logger.error("CRITICAL: No download tools found (wget, curl, or aria2c)")
|
||||
self.logger.error("Winetricks requires at least ONE download tool to install components")
|
||||
self.logger.error("")
|
||||
self.logger.error("SOLUTION: Install one of the following:")
|
||||
self.logger.error(" - aria2c (preferred): sudo apt install aria2 # or equivalent for your distro")
|
||||
self.logger.error(" - curl: sudo apt install curl # or equivalent for your distro")
|
||||
self.logger.error(" - wget: sudo apt install wget # or equivalent for your distro")
|
||||
self.logger.error("=" * 80)
|
||||
else:
|
||||
self.logger.warning("Critical dependencies: wget/curl (download), unzip/7z (extract)")
|
||||
self.logger.info("Optional dependencies: aria2c (preferred but not required)")
|
||||
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
|
||||
@@ -268,11 +432,18 @@ class WinetricksHandler:
|
||||
|
||||
if not all_components:
|
||||
self.logger.info("No Wine components to install.")
|
||||
if status_callback:
|
||||
status_callback("No Wine components to install")
|
||||
return True
|
||||
|
||||
# Reorder components for proper installation sequence
|
||||
components_to_install = self._reorder_components_for_installation(all_components)
|
||||
self.logger.info(f"WINEPREFIX: {wineprefix}, Game: {game_var}, Ordered Components: {components_to_install}")
|
||||
|
||||
# Show status with component list
|
||||
if status_callback:
|
||||
components_list = ', '.join(components_to_install)
|
||||
status_callback(f"Installing Wine components: {components_list}")
|
||||
|
||||
# Check user preference for component installation method
|
||||
from ..handlers.config_handler import ConfigHandler
|
||||
@@ -289,11 +460,17 @@ class WinetricksHandler:
|
||||
|
||||
# Choose installation method based on user preference
|
||||
if method == 'system_protontricks':
|
||||
self.logger.info("=" * 80)
|
||||
self.logger.info("USING PROTONTRICKS")
|
||||
self.logger.info("=" * 80)
|
||||
self.logger.info("Using system protontricks for all components")
|
||||
return self._install_components_protontricks_only(components_to_install, wineprefix, game_var)
|
||||
return self._install_components_protontricks_only(components_to_install, wineprefix, game_var, status_callback)
|
||||
# else: method == 'winetricks' (default behavior continues below)
|
||||
|
||||
# Install all components together with winetricks (faster)
|
||||
self.logger.info("=" * 80)
|
||||
self.logger.info("USING WINETRICKS")
|
||||
self.logger.info("=" * 80)
|
||||
max_attempts = 3
|
||||
winetricks_failed = False
|
||||
last_error_details = None
|
||||
@@ -358,6 +535,9 @@ class WinetricksHandler:
|
||||
# Verify components were actually installed
|
||||
if self._verify_components_installed(wineprefix, components_to_install, env):
|
||||
self.logger.info("Component verification successful - all components installed correctly.")
|
||||
components_list = ', '.join(components_to_install)
|
||||
if status_callback:
|
||||
status_callback(f"Wine components installed and verified: {components_list}")
|
||||
# Set Windows 10 mode after component installation (matches legacy script timing)
|
||||
self._set_windows_10_mode(wineprefix, env.get('WINE', ''))
|
||||
return True
|
||||
@@ -373,40 +553,78 @@ 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()}")
|
||||
# Log full error details to help diagnose failures
|
||||
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 ("please install" in stderr_lower or "please install" in stdout_lower) and ("wget" in stderr_lower or "aria2c" in stderr_lower or "curl" in stderr_lower or "wget" in stdout_lower or "aria2c" in stdout_lower or "curl" in stdout_lower):
|
||||
# Winetricks explicitly says to install a downloader
|
||||
self._handle_missing_downloader_error()
|
||||
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
|
||||
|
||||
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
|
||||
@@ -422,7 +640,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)
|
||||
@@ -452,16 +683,19 @@ 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)
|
||||
self.logger.warning("AUTOMATIC FALLBACK: Winetricks failed, attempting protontricks fallback...")
|
||||
self.logger.warning(f"Last winetricks error: {last_error_details}")
|
||||
self.logger.warning("=" * 80)
|
||||
self.logger.info("=" * 80)
|
||||
self.logger.info("USING PROTONTRICKS")
|
||||
self.logger.info("=" * 80)
|
||||
|
||||
# Attempt fallback to protontricks
|
||||
fallback_success = self._install_components_protontricks_only(components_to_install, wineprefix, game_var)
|
||||
fallback_success = self._install_components_protontricks_only(components_to_install, wineprefix, game_var, status_callback)
|
||||
|
||||
if fallback_success:
|
||||
self.logger.info("SUCCESS: Protontricks fallback succeeded where winetricks failed")
|
||||
@@ -479,6 +713,53 @@ class WinetricksHandler:
|
||||
|
||||
return False
|
||||
|
||||
def _handle_missing_downloader_error(self):
|
||||
"""Handle winetricks error indicating missing downloader - provide platform-specific instructions"""
|
||||
from ..services.platform_detection_service import PlatformDetectionService
|
||||
|
||||
platform = PlatformDetectionService.get_instance()
|
||||
is_steamos = platform.is_steamdeck
|
||||
|
||||
self.logger.error("=" * 80)
|
||||
self.logger.error("CRITICAL: Winetricks cannot find a downloader (curl, wget, or aria2c)")
|
||||
self.logger.error("")
|
||||
|
||||
if is_steamos:
|
||||
self.logger.error("STEAMOS/STEAM DECK DETECTED")
|
||||
self.logger.error("")
|
||||
self.logger.error("SteamOS has a read-only filesystem. To install packages:")
|
||||
self.logger.error("")
|
||||
self.logger.error("1. Disable read-only mode (required for package installation):")
|
||||
self.logger.error(" sudo steamos-readonly disable")
|
||||
self.logger.error("")
|
||||
self.logger.error("2. Install curl (recommended - most reliable):")
|
||||
self.logger.error(" sudo pacman -S curl")
|
||||
self.logger.error("")
|
||||
self.logger.error("3. (Optional) Re-enable read-only mode after installation:")
|
||||
self.logger.error(" sudo steamos-readonly enable")
|
||||
self.logger.error("")
|
||||
self.logger.error("Note: curl is usually pre-installed on SteamOS. If missing,")
|
||||
self.logger.error(" the above steps will install it.")
|
||||
else:
|
||||
self.logger.error("SOLUTION: Install one of the following downloaders:")
|
||||
self.logger.error("")
|
||||
self.logger.error(" For Debian/Ubuntu/PopOS:")
|
||||
self.logger.error(" sudo apt install curl # or: sudo apt install wget")
|
||||
self.logger.error("")
|
||||
self.logger.error(" For Fedora/RHEL/CentOS:")
|
||||
self.logger.error(" sudo dnf install curl # or: sudo dnf install wget")
|
||||
self.logger.error("")
|
||||
self.logger.error(" For Arch/Manjaro:")
|
||||
self.logger.error(" sudo pacman -S curl # or: sudo pacman -S wget")
|
||||
self.logger.error("")
|
||||
self.logger.error(" For openSUSE:")
|
||||
self.logger.error(" sudo zypper install curl # or: sudo zypper install wget")
|
||||
self.logger.error("")
|
||||
self.logger.error("Note: Most Linux distributions include curl by default.")
|
||||
self.logger.error(" If curl is missing, install it using your package manager.")
|
||||
|
||||
self.logger.error("=" * 80)
|
||||
|
||||
def _reorder_components_for_installation(self, components: list) -> list:
|
||||
"""
|
||||
Reorder components for proper installation sequence if needed.
|
||||
@@ -566,7 +847,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')
|
||||
@@ -578,8 +859,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'])
|
||||
@@ -698,7 +979,7 @@ class WinetricksHandler:
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error setting Windows 10 mode: {e}")
|
||||
|
||||
def _install_components_protontricks_only(self, components: list, wineprefix: str, game_var: str) -> bool:
|
||||
def _install_components_protontricks_only(self, components: list, wineprefix: str, game_var: str, status_callback: Optional[Callable[[str], None]] = None) -> bool:
|
||||
"""
|
||||
Install all components using protontricks only.
|
||||
This matches the behavior of the original bash script.
|
||||
@@ -732,6 +1013,9 @@ class WinetricksHandler:
|
||||
return False
|
||||
|
||||
# Install all components using protontricks
|
||||
components_list = ', '.join(components)
|
||||
if status_callback:
|
||||
status_callback(f"Installing Wine components via protontricks: {components_list}")
|
||||
success = protontricks_handler.install_wine_components(appid, game_var, components)
|
||||
|
||||
if success:
|
||||
@@ -792,7 +1076,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')
|
||||
@@ -803,10 +1087,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'])
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -30,7 +30,8 @@ class AutomatedPrefixService:
|
||||
"""
|
||||
|
||||
def __init__(self, system_info=None):
|
||||
self.scripts_dir = Path.home() / "Jackify/scripts"
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
self.scripts_dir = get_jackify_data_dir() / "scripts"
|
||||
self.scripts_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.system_info = system_info
|
||||
# Use shared timing for consistency across services
|
||||
@@ -70,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()
|
||||
@@ -492,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
|
||||
@@ -749,7 +750,8 @@ echo Creating Proton prefix...
|
||||
timeout /t 3 /nobreak >nul
|
||||
echo Prefix creation complete.
|
||||
"""
|
||||
batch_path = Path.home() / "Jackify/temp_prefix_creation.bat"
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
batch_path = get_jackify_data_dir() / "temp_prefix_creation.bat"
|
||||
batch_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(batch_path, 'w') as f:
|
||||
@@ -2883,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"
|
||||
@@ -3043,24 +3046,43 @@ 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):
|
||||
stripped_line = line.strip()
|
||||
if stripped_line == section_name:
|
||||
# Case-insensitive comparison for section name (Wine registry is case-insensitive)
|
||||
if stripped_line.split(']')[0].lower() + ']' == section_name.lower() if ']' in stripped_line else stripped_line.lower() == section_name.lower():
|
||||
in_target_section = True
|
||||
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:
|
||||
@@ -3093,20 +3115,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
|
||||
@@ -3138,39 +3160,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")
|
||||
@@ -3187,7 +3266,7 @@ echo Prefix creation complete.
|
||||
"22380": { # Fallout New Vegas AppID
|
||||
"name": "Fallout New Vegas",
|
||||
"common_names": ["Fallout New Vegas", "FalloutNV"],
|
||||
"registry_section": "[Software\\\\WOW6432Node\\\\bethesda softworks\\\\falloutnv]",
|
||||
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\falloutnv]",
|
||||
"path_key": "Installed Path"
|
||||
},
|
||||
"976620": { # Enderal Special Edition AppID
|
||||
|
||||
@@ -25,7 +25,8 @@ from jackify.shared.paths import get_jackify_data_dir
|
||||
class ModlistGalleryService:
|
||||
"""Service for fetching and caching modlist metadata from jackify-engine"""
|
||||
|
||||
CACHE_VALIDITY_DAYS = 7 # Refresh cache after 7 days
|
||||
# REMOVED: CACHE_VALIDITY_DAYS - metadata is now always fetched fresh from engine
|
||||
# Images are still cached indefinitely (managed separately)
|
||||
# CRITICAL: Thread lock to prevent concurrent engine calls that could cause recursive spawning
|
||||
_engine_call_lock = threading.Lock()
|
||||
|
||||
@@ -59,31 +60,20 @@ class ModlistGalleryService:
|
||||
"""
|
||||
Fetch modlist metadata from jackify-engine.
|
||||
|
||||
NOTE: Metadata is ALWAYS fetched fresh from the engine to ensure up-to-date
|
||||
version numbers and sizes for frequently-updated modlists. Only images are cached.
|
||||
|
||||
Args:
|
||||
include_validation: Include validation status (slower)
|
||||
include_search_index: Include mod search index (slower)
|
||||
sort_by: Sort order (title, size, date)
|
||||
force_refresh: Force refresh even if cache is valid
|
||||
force_refresh: Deprecated parameter (kept for API compatibility)
|
||||
|
||||
Returns:
|
||||
ModlistMetadataResponse or None if fetch fails
|
||||
"""
|
||||
# Check cache first unless force refresh
|
||||
# If include_search_index is True, check if cache has mods before using it
|
||||
if not force_refresh:
|
||||
cached = self._load_from_cache()
|
||||
if cached and self._is_cache_valid():
|
||||
# If we need search index, check if cached data has mods
|
||||
if include_search_index:
|
||||
# Check if at least one modlist has mods (indicates cache was built with search index)
|
||||
has_mods = any(hasattr(m, 'mods') and m.mods for m in cached.modlists)
|
||||
if has_mods:
|
||||
return cached # Cache has mods, use it
|
||||
# Cache doesn't have mods, need to fetch fresh
|
||||
else:
|
||||
return cached # Don't need search index, use cache
|
||||
|
||||
# Fetch fresh data from jackify-engine
|
||||
# Always fetch fresh data from jackify-engine
|
||||
# The engine itself is fast (~1-2 seconds) and always gets latest metadata
|
||||
try:
|
||||
metadata = self._fetch_from_engine(
|
||||
include_validation=include_validation,
|
||||
@@ -91,6 +81,7 @@ class ModlistGalleryService:
|
||||
sort_by=sort_by
|
||||
)
|
||||
|
||||
# Still save to cache as a fallback for offline scenarios
|
||||
if metadata:
|
||||
self._save_to_cache(metadata)
|
||||
|
||||
@@ -98,7 +89,8 @@ class ModlistGalleryService:
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching modlist metadata: {e}")
|
||||
# Fall back to cache if available
|
||||
print("Falling back to cached metadata (may be outdated)")
|
||||
# Fall back to cache if network/engine fails
|
||||
return self._load_from_cache()
|
||||
|
||||
def _fetch_from_engine(
|
||||
@@ -253,17 +245,6 @@ class ModlistGalleryService:
|
||||
|
||||
return result
|
||||
|
||||
def _is_cache_valid(self) -> bool:
|
||||
"""Check if cache is still valid based on age"""
|
||||
if not self.METADATA_CACHE_FILE.exists():
|
||||
return False
|
||||
|
||||
# Check file modification time
|
||||
mtime = datetime.fromtimestamp(self.METADATA_CACHE_FILE.stat().st_mtime)
|
||||
age = datetime.now() - mtime
|
||||
|
||||
return age < timedelta(days=self.CACHE_VALIDITY_DAYS)
|
||||
|
||||
def download_images(
|
||||
self,
|
||||
game_filter: Optional[str] = None,
|
||||
|
||||
@@ -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)
|
||||
@@ -288,15 +297,6 @@ class ModlistService:
|
||||
# Build command (copied from working code)
|
||||
cmd = [engine_path, 'install', '--show-file-progress']
|
||||
|
||||
# Check GPU setting
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
gpu_enabled = config_handler.get('enable_gpu_texture_conversion', True)
|
||||
logger.info(f"GPU texture conversion setting: {gpu_enabled}")
|
||||
if not gpu_enabled:
|
||||
cmd.append('--no-gpu')
|
||||
logger.info("Added --no-gpu flag to jackify-engine command")
|
||||
|
||||
modlist_value = context.get('modlist_value')
|
||||
if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value):
|
||||
cmd += ['-w', modlist_value]
|
||||
@@ -311,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])
|
||||
@@ -547,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
|
||||
|
||||
@@ -590,7 +628,7 @@ class ModlistService:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to configure modlist {context.name}: {e}")
|
||||
if completion_callback:
|
||||
completion_callback(False, f"Configuration failed: {e}", context.name)
|
||||
completion_callback(False, f"Configuration failed: {e}", context.name, False)
|
||||
|
||||
# Clean up GUI log handler on exception
|
||||
if gui_log_handler:
|
||||
@@ -657,11 +695,11 @@ class ModlistService:
|
||||
if success:
|
||||
logger.info("Modlist configuration completed successfully")
|
||||
if completion_callback:
|
||||
completion_callback(True, "Configuration completed successfully", context.name)
|
||||
completion_callback(True, "Configuration completed successfully", context.name, False)
|
||||
else:
|
||||
logger.warning("Modlist configuration had issues")
|
||||
if completion_callback:
|
||||
completion_callback(False, "Configuration failed", context.name)
|
||||
completion_callback(False, "Configuration failed", context.name, False)
|
||||
|
||||
return success
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ class NativeSteamOperationsService:
|
||||
|
||||
# Also check additional Steam libraries via libraryfolders.vdf
|
||||
try:
|
||||
from jackify.shared.paths import PathHandler
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
all_steam_libs = PathHandler.get_all_steam_library_paths()
|
||||
|
||||
for lib_path in all_steam_libs:
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,9 +41,9 @@ class PlatformDetectionService:
|
||||
if os.path.exists('/etc/os-release'):
|
||||
with open('/etc/os-release', 'r') as f:
|
||||
content = f.read().lower()
|
||||
if 'steamdeck' in content:
|
||||
if 'steamdeck' in content or 'steamos' in content:
|
||||
self._is_steamdeck = True
|
||||
logger.info("Steam Deck platform detected")
|
||||
logger.info("Steam Deck/SteamOS platform detected")
|
||||
else:
|
||||
logger.debug("Non-Steam Deck Linux platform detected")
|
||||
else:
|
||||
|
||||
@@ -132,14 +132,31 @@ class ProtontricksDetectionService:
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
# Install command
|
||||
install_cmd = ["flatpak", "install", "-u", "-y", "--noninteractive", "flathub", "com.github.Matoking.protontricks"]
|
||||
# Install command - use --user flag for user-level installation (works on Steam Deck)
|
||||
# This avoids requiring system-wide installation permissions
|
||||
install_cmd = ["flatpak", "install", "--user", "-y", "--noninteractive", "flathub", "com.github.Matoking.protontricks"]
|
||||
|
||||
# Use clean environment
|
||||
env = handler._get_clean_subprocess_env()
|
||||
|
||||
# Run installation
|
||||
process = subprocess.run(install_cmd, check=True, text=True, env=env, capture_output=True)
|
||||
# Log the command for debugging
|
||||
logger.debug(f"Running flatpak install command: {' '.join(install_cmd)}")
|
||||
|
||||
# Run installation with timeout (5 minutes should be plenty)
|
||||
process = subprocess.run(
|
||||
install_cmd,
|
||||
check=True,
|
||||
text=True,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
timeout=300 # 5 minute timeout
|
||||
)
|
||||
|
||||
# Log stdout/stderr for debugging (even on success, might contain useful info)
|
||||
if process.stdout:
|
||||
logger.debug(f"Flatpak install stdout: {process.stdout}")
|
||||
if process.stderr:
|
||||
logger.debug(f"Flatpak install stderr: {process.stderr}")
|
||||
|
||||
# Clear cache to force re-detection
|
||||
self._cached_detection_valid = False
|
||||
@@ -152,13 +169,41 @@ class ProtontricksDetectionService:
|
||||
error_msg = "Flatpak command not found. Please install Flatpak first."
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
except subprocess.CalledProcessError as e:
|
||||
error_msg = f"Flatpak installation failed: {e}"
|
||||
except subprocess.TimeoutExpired:
|
||||
error_msg = "Flatpak installation timed out after 5 minutes. Please check your network connection and try again."
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Include stderr in error message for better debugging
|
||||
stderr_msg = e.stderr.strip() if e.stderr else "No error details available"
|
||||
stdout_msg = e.stdout.strip() if e.stdout else ""
|
||||
|
||||
# Try to extract meaningful error from stderr
|
||||
if stderr_msg:
|
||||
# Common errors: permission denied, network issues, etc.
|
||||
if "permission" in stderr_msg.lower() or "denied" in stderr_msg.lower():
|
||||
error_msg = f"Permission denied. Try running: flatpak install --user flathub com.github.Matoking.protontricks\n\nDetails: {stderr_msg}"
|
||||
elif "network" in stderr_msg.lower() or "connection" in stderr_msg.lower():
|
||||
error_msg = f"Network error during installation. Check your internet connection.\n\nDetails: {stderr_msg}"
|
||||
elif "already installed" in stderr_msg.lower():
|
||||
# This might actually be success - clear cache and re-detect
|
||||
logger.info("Protontricks appears to already be installed (according to flatpak output)")
|
||||
self._cached_detection_valid = False
|
||||
return True, "Protontricks is already installed."
|
||||
else:
|
||||
error_msg = f"Flatpak installation failed:\n\n{stderr_msg}"
|
||||
if stdout_msg:
|
||||
error_msg += f"\n\nOutput: {stdout_msg}"
|
||||
else:
|
||||
error_msg = f"Flatpak installation failed with return code {e.returncode}."
|
||||
if stdout_msg:
|
||||
error_msg += f"\n\nOutput: {stdout_msg}"
|
||||
|
||||
logger.error(f"Flatpak installation error: {error_msg}")
|
||||
return False, error_msg
|
||||
except Exception as e:
|
||||
error_msg = f"Unexpected error during Flatpak installation: {e}"
|
||||
logger.error(error_msg)
|
||||
logger.error(error_msg, exc_info=True)
|
||||
return False, error_msg
|
||||
|
||||
def get_installation_guidance(self) -> str:
|
||||
|
||||
@@ -150,8 +150,14 @@ def is_flatpak_steam() -> bool:
|
||||
stderr=subprocess.DEVNULL, # Suppress stderr to avoid error messages
|
||||
text=True,
|
||||
timeout=5)
|
||||
if result.returncode == 0 and 'com.valvesoftware.Steam' in result.stdout:
|
||||
return True
|
||||
if result.returncode == 0:
|
||||
# Check for exact match - "com.valvesoftware.Steam" as a whole word
|
||||
# This prevents matching "com.valvesoftware.SteamLink" or similar
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if parts and parts[0] == 'com.valvesoftware.Steam':
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.debug(f"Error detecting Flatpak Steam: {e}")
|
||||
return False
|
||||
@@ -222,7 +228,8 @@ def _start_steam_nak_style(is_steamdeck_flag=False, is_flatpak_flag=False, env_o
|
||||
subprocess.Popen("steam", shell=True, env=env)
|
||||
|
||||
time.sleep(5)
|
||||
check_result = subprocess.run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10, env=env)
|
||||
# Use steamwebhelper for detection (actual Steam process, not steam-powerbuttond)
|
||||
check_result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=env)
|
||||
if check_result.returncode == 0:
|
||||
logger.info("NaK-style restart detected running Steam process.")
|
||||
return True
|
||||
@@ -282,7 +289,8 @@ def start_steam(is_steamdeck_flag=None, is_flatpak_flag=None, env_override=None,
|
||||
subprocess.Popen(["flatpak", "run", "com.valvesoftware.Steam"],
|
||||
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
time.sleep(7) # Give Flatpak more time to start
|
||||
check_result = subprocess.run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10, env=env)
|
||||
# For Flatpak Steam, check for the flatpak process, not steamwebhelper
|
||||
check_result = subprocess.run(['pgrep', '-f', 'com.valvesoftware.Steam'], capture_output=True, timeout=10, env=env)
|
||||
if check_result.returncode == 0:
|
||||
logger.info("Flatpak Steam started successfully")
|
||||
return True
|
||||
@@ -308,7 +316,8 @@ def start_steam(is_steamdeck_flag=None, is_flatpak_flag=None, env_override=None,
|
||||
if process is not None:
|
||||
logger.info(f"Initiated Steam start with {method_name}.")
|
||||
time.sleep(5) # Wait 5 seconds as in existing logic
|
||||
check_result = subprocess.run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10, env=env)
|
||||
# Use steamwebhelper for detection (actual Steam process, not steam-powerbuttond)
|
||||
check_result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=env)
|
||||
if check_result.returncode == 0:
|
||||
logger.info(f"Steam process detected after using {method_name}. Proceeding to wait phase.")
|
||||
return True
|
||||
@@ -367,7 +376,7 @@ def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = No
|
||||
try:
|
||||
report("Flatpak Steam detected - stopping via flatpak...")
|
||||
subprocess.run(['flatpak', 'kill', 'com.valvesoftware.Steam'],
|
||||
timeout=15, check=False, capture_output=True, stderr=subprocess.DEVNULL, env=shutdown_env)
|
||||
timeout=15, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=shutdown_env)
|
||||
time.sleep(2)
|
||||
except Exception as e:
|
||||
logger.debug(f"flatpak kill failed: {e}")
|
||||
@@ -393,14 +402,15 @@ def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = No
|
||||
if final_check.returncode != 0:
|
||||
logger.info("Steam processes successfully force terminated.")
|
||||
else:
|
||||
report("Failed to terminate Steam processes.")
|
||||
return False
|
||||
# Steam might still be running, but proceed anyway - wait phase will verify
|
||||
logger.warning("Steam processes may still be running after termination attempts. Proceeding to start phase...")
|
||||
report("Steam shutdown incomplete, but proceeding...")
|
||||
else:
|
||||
logger.info("Steam processes successfully terminated.")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during Steam shutdown: {e}")
|
||||
report("Failed to shut down Steam.")
|
||||
return False
|
||||
# Don't fail completely on shutdown errors - proceed to start phase
|
||||
logger.warning(f"Error during Steam shutdown: {e}. Proceeding to start phase anyway...")
|
||||
report("Steam shutdown had issues, but proceeding...")
|
||||
|
||||
report("Steam closed successfully.")
|
||||
|
||||
@@ -418,40 +428,56 @@ def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = No
|
||||
return False
|
||||
else:
|
||||
# All other distros: Use start_steam() which now uses -foreground to ensure GUI opens
|
||||
if not start_steam(
|
||||
steam_started = start_steam(
|
||||
is_steamdeck_flag=_is_steam_deck,
|
||||
is_flatpak_flag=_is_flatpak,
|
||||
env_override=start_env,
|
||||
strategy=strategy,
|
||||
):
|
||||
report("Failed to start Steam.")
|
||||
return False
|
||||
)
|
||||
# Even if start_steam() returns False, Steam might still be starting
|
||||
# Give it a chance by proceeding to wait phase
|
||||
if not steam_started:
|
||||
logger.warning("start_steam() returned False, but proceeding to wait phase in case Steam is starting anyway")
|
||||
report("Steam start command issued, waiting for process...")
|
||||
|
||||
# Wait for Steam to fully initialize using existing logic
|
||||
# Wait for Steam to fully initialize
|
||||
# CRITICAL: Use steamwebhelper (actual Steam process), not "steam" (matches steam-powerbuttond, etc.)
|
||||
report("Waiting for Steam to fully start")
|
||||
logger.info("Waiting up to 2 minutes for Steam to fully initialize...")
|
||||
max_startup_wait = 120
|
||||
logger.info("Waiting up to 3 minutes (180 seconds) for Steam to fully initialize...")
|
||||
max_startup_wait = 180 # Increased from 120 to 180 seconds (3 minutes) for slower systems
|
||||
elapsed_wait = 0
|
||||
initial_wait_done = False
|
||||
last_status_log = 0 # Track when we last logged status
|
||||
|
||||
while elapsed_wait < max_startup_wait:
|
||||
try:
|
||||
result = subprocess.run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10, env=start_env)
|
||||
# Log status every 30 seconds so user knows we're still waiting
|
||||
if elapsed_wait - last_status_log >= 30:
|
||||
remaining = max_startup_wait - elapsed_wait
|
||||
logger.info(f"Still waiting for Steam... ({elapsed_wait}s elapsed, {remaining}s remaining)")
|
||||
if progress_callback:
|
||||
progress_callback(f"Waiting for Steam... ({elapsed_wait}s / {max_startup_wait}s)")
|
||||
last_status_log = elapsed_wait
|
||||
|
||||
# Use steamwebhelper for detection (matches shutdown logic)
|
||||
result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=start_env)
|
||||
if result.returncode == 0:
|
||||
if not initial_wait_done:
|
||||
logger.info("Steam process detected. Waiting additional time for full initialization...")
|
||||
logger.info(f"Steam process detected at {elapsed_wait}s. Waiting additional time for full initialization...")
|
||||
initial_wait_done = True
|
||||
time.sleep(5)
|
||||
elapsed_wait += 5
|
||||
if initial_wait_done and elapsed_wait >= 15:
|
||||
final_check = subprocess.run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10, env=start_env)
|
||||
# Require at least 20 seconds of stable detection (increased from 15)
|
||||
if initial_wait_done and elapsed_wait >= 20:
|
||||
final_check = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=start_env)
|
||||
if final_check.returncode == 0:
|
||||
report("Steam started successfully.")
|
||||
logger.info("Steam confirmed running after wait.")
|
||||
logger.info(f"Steam confirmed running after {elapsed_wait}s wait.")
|
||||
return True
|
||||
else:
|
||||
logger.warning("Steam process disappeared during final initialization wait.")
|
||||
break
|
||||
logger.warning("Steam process disappeared during final initialization wait, continuing to wait...")
|
||||
# Don't break - continue waiting in case Steam is still starting
|
||||
initial_wait_done = False # Reset to allow re-detection
|
||||
else:
|
||||
logger.debug(f"Steam process not yet detected. Waiting... ({elapsed_wait + 5}s)")
|
||||
time.sleep(5)
|
||||
@@ -461,6 +487,7 @@ def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = No
|
||||
time.sleep(5)
|
||||
elapsed_wait += 5
|
||||
|
||||
report("Steam did not start within timeout.")
|
||||
logger.error("Steam failed to start/initialize within the allowed time.")
|
||||
# Only reach here if we've waited the full duration
|
||||
report(f"Steam did not start within {max_startup_wait}s timeout.")
|
||||
logger.error(f"Steam failed to start/initialize within the allowed time ({elapsed_wait}s elapsed).")
|
||||
return False
|
||||
@@ -271,9 +271,9 @@ class UpdateService:
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
downloaded_size = 0
|
||||
|
||||
# Create update directory in user's home directory
|
||||
home_dir = Path.home()
|
||||
update_dir = home_dir / "Jackify" / "updates"
|
||||
# Create update directory in user's data directory
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
update_dir = get_jackify_data_dir() / "updates"
|
||||
update_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
temp_file = update_dir / f"Jackify-{update_info.version}.AppImage"
|
||||
@@ -345,9 +345,9 @@ class UpdateService:
|
||||
Path to helper script, or None if creation failed
|
||||
"""
|
||||
try:
|
||||
# Create update directory in user's home directory
|
||||
home_dir = Path.home()
|
||||
update_dir = home_dir / "Jackify" / "updates"
|
||||
# Create update directory in user's data directory
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
update_dir = get_jackify_data_dir() / "updates"
|
||||
update_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
helper_script = update_dir / "update_helper.sh"
|
||||
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user